HTML5-游戏构建指南-全-

HTML5 游戏构建指南(全)

原文:zh.annas-archive.org/md5/82226a707795e265763f451da43f493a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

游戏无处不在,而且它们越来越多地在连接的网页设备以及桌面和移动浏览器环境中进行游戏。随着基于浏览器的游戏越来越受欢迎,玩家们开始转向像 Facebook 这样的网站,发现一些无需光盘或过多前期设置即可玩的简单休闲游戏。一款游戏也不过是另一个可以点击的链接。

在过去的十年里,Adobe 的 Flash 插件的改进促进了网络浏览器作为游戏平台的发展。大多数浏览器都支持 Flash,这为游戏开发者提供了一个强大的平台,接近了一次编写,到处运行的梦想。基于 HTML 的游戏也已经存在了相当长的时间,你可能甚至玩过一些(可能没有注意到)。然而,直到最近,由于图形、音频和速度的限制,HTML 和 JavaScript 作为游戏平台的使用一直处于 Flash 的“配角”地位。但浏览器和移动游戏平台已经得到了极大的改进,现状正在发生变化。移动操作系统已不再支持 Flash 插件,因此,游戏开发者需要能够提供类似性能和灵活性同时又能保持 Flash 普及性的工具。

浏览器在过去几年中,图形和音频能力也得到了快速提升。HTML 的强大功能与日益增长的对提供丰富游戏体验的跨平台需求相对应,且得到了多个平台提供商的支持。一个得到良好支持的开放平台被认为不太可能受制于商业控制或封闭的“花园围墙”心态,而 HTML5 正是这样一个平台。

然而,根据我的经验,许多游戏开发者在接触 HTML5 时,都是希望构建他们曾在 Flash 中开发的那种类型的游戏。HTML5 无疑是最好的选择之一:它有着庞大的用户基础(任何拥有现代浏览器的人都可以使用),并且 HTML5 与 Flash 有许多相似的功能和限制。尽管如此,将 HTML5 视为 Flash 的替代品,可能会导致令人失望的产品发布和错失的机会。这是因为,二者的优点并不完全重合。而且,HTML5 仍处于相对早期的发展阶段。这个平台在快速发展,且每个月支持的新功能变化很快,保持同步可能会变得困难。

就像构建一个好的 Web 应用程序一样,制作一款成功的游戏的关键是理解你平台的能力和限制。你必须设计和构建能够最大化平台潜力的游戏,同时避免或最小化其局限性。本书旨在作为理解如何利用 JavaScript、HTML 和 CSS 实现目标的第一步,并介绍你可以使用的方法。

为什么要构建 HTML5 游戏?

在我深入本书的具体内容之前,先让我们回过头来思考一下,你为什么要在 HTML5 平台上创建一款游戏。

利用你已有的技能

精通 JavaScript 和 CSS 的 Web 开发者会更有信心进入 HTML5 游戏开发领域。部署 HTML 和 JavaScript 文件也是一个熟悉的过程,你可以使用与 Web 开发重叠的服务器端语言来构建在线组件。

但如果你投身于编写 C++ 或 Objective-C,新的编程语言、新的开发环境以及游戏开发所需的新思维方式可能会导致陡峭的学习曲线。简而言之,与其他游戏技术相比,从 Web 开发转向 HTML5 游戏开发所需的概念跃迁相对较小。

多环境开发

许多平台承诺实现“一次编写,随处运行”的能力,在我看来,HTML5 是任何技术中最接近实现这一目标的。无论是为桌面浏览器还是打包的移动应用开发,你的编码风格不会有太大差异,屏幕上展示对象和用户与之互动的基本技术也没有变化。当然,总会有一些特定环境的差异,特别是如果代码需要利用某个环境提供的特性和优势时。

尽管如此,用 HTML5 和 JavaScript 编写的游戏仍然有很大的机会能在多个操作系统上以最小的修改正常运行。这使得可以同时发布并由一个开发团队负责,而不是为每个系统配备一个团队。即使最终环境不同,你也可以在桌面浏览器中进行编码和测试。

快速发展的平台

HTML5 正在不断快速改进。JavaScript 的处理速度也在提升,一些复杂的解释器正在接近本地代码的执行速度。考虑到过去 10 年 CPU 速度的提升,使用 JavaScript 编写的游戏可以比几年前许多本地代码编写的游戏表现得更好。

在浏览器厂商和硬件制造商的努力下,这一改进趋势只会继续下去,毫无疑问,HTML5 正在成长为一个可行的游戏平台。无论 HTML5 游戏开发是否会作为一种快速开发环境,在移动或桌面浏览器上开发沉浸式 3D 游戏,还是作为一个快速原型开发环境供休闲游戏开发者使用,甚至通过 Android 或其他设备进入主机环境,现在都是 JavaScript 程序员的激动时刻。现在正是建立在本书所学知识的基础上,并尝试 HTML5 和 JavaScript 作为开放式游戏开发平台的能力的时候。

本书介绍

本书无法展示所有可能的 HTML5 游戏,因此也未能全面探索 HTML5 和 JavaScript 的所有功能。相反,我专注于创建一个简单的休闲游戏,类似于许多开发者多年来使用 Adobe Flash 制作的游戏。这些游戏通常是二维的,单人游戏,并且具有相对较短的游戏循环。3D 技术的进步,如 WebGL,意味着大型、复杂、沉浸式的多人游戏现在已经可能或者即将实现,但对于游戏开发者而言,休闲游戏项目是一个更自然的起点。简单的项目也更容易展示在制作游戏过程中涉及的基本原理。

本书适合谁阅读

本书适合那些熟悉 HTML、CSS 和 JavaScript 的 web 开发者,尤其是那些想将现有技能转化为游戏开发的开发者。最低要求是你应该会编程,理想情况下你应该了解 JavaScript 的基础知识。你还应该有一个自己的 web 服务器和开发环境,或者能够为自己设置这些环境。

如果你有一定的 web 或游戏技术背景,想要了解 HTML5 能做什么,并且有学习和实验的热情,你应该能够顺利完成本书的内容。到最后,你将清楚如何开展 HTML5 游戏开发项目,并对制作游戏的核心流程有一个全面的了解。

概述

在整本书中,你将开发一个简单的气泡弹射游戏,目的是让它在浏览器中运行。每一章我都会通过实践介绍新的概念。

第一部分: 使用 HTML、CSS 和 JavaScript 构建游戏 中,你将使用 HTML、CSS 和 JavaScript 构建一个完整的游戏,这一部分包括本书的前四章。

  • 第一章: 准备和设置 介绍了我们需要的工具,包括 jQuery 和 Modernizr 脚本库,如何调试,以及如何建立游戏的文件结构。

  • 第二章: 使用 jQuery 和 CSS 进行精灵动画 描述了如何响应鼠标点击移动 HTML 元素。在游戏中,这意味着将图像从起始位置射向玩家点击的坐标。

  • 第三章: 游戏逻辑 让你绘制游戏板并设置大部分游戏逻辑,包括发射气泡和碰撞检测。

  • 第四章:将游戏状态变化转换为显示 教你如何让游戏响应我们在第三章中检测到的碰撞,并增加更多的游戏逻辑来弹出泡泡组。这引入了通过爆炸效果在对象内进行基本动画的概念。

第二部分中,你将使用 HTML5 和 Canvas 中的新特性改进在第一部分中创建的游戏。

  • 第五章:CSS 过渡与变换 向你展示如何使用 CSS3 实现一些你在之前章节中使用 jQuery 来完成的效果。

  • 第六章:渲染 Canvas 精灵 向你展示如何在 HTML5 的 canvas 中完全渲染游戏,包括在屏幕上移动物体和动画效果。

  • 第七章:关卡、声音及更多内容 解决了游戏逻辑中的一些问题,引入了更流畅的动画技术,并展示了如何实现声音效果以及保存玩家的分数。

  • 第八章:HTML5 的下一步 讨论了一些你在开发休闲游戏时不需要使用的有用技术。它建议了未来的学习领域,如 Web Workers 和用于 3D 游戏的 WebGL,并讨论了重要问题,如内存管理和速度优化。

  • 最后,后记 提供了一些提升你 HTML5 游戏编程技能的思路。例如,你可以继续改进你在本书中构建的游戏,或者开始开发自己的游戏创意。

本书中的所有代码都可以从 buildanhtml5game.com/ 下载,在那里你也可以看到你将要构建的游戏的演示版本。并且在每章的结尾,我都会提供练习题,以测试你的技能并激发改进泡泡射手游戏的创意。

覆盖深度

由于本书专注于休闲游戏开发,我不会详细讲解 WebGL、三维建模、着色器、纹理、光照以及其他与更复杂的游戏(如第一人称射击游戏或大型多人在线角色扮演游戏 MMORPG)相关的技术。这些主题本身就足以写成书。然而,你会发现构建休闲游戏的大部分原则在更具技术挑战的场景中同样适用。我建议保持初期项目的可实现性,在完成几个版本之后,再向更复杂的项目迈进。一旦你完成了几个使用 HTML、CSS 和 canvas 的项目,你将有能力学习更多关于 WebGL 的知识,如果你有意朝这个方向发展;然而,你可能会发现休闲游戏领域已经提供了足够多的开发机会。

本书向你介绍了游戏开发技术,但它并不是你将要使用的应用程序接口(API)的详尽参考,也不是 HTML5 的完整指南:它仅涵盖了与游戏开发最相关的功能。互联网上充满了不仅提供更多细节的材料,而且这些材料也会随时更新,以适应不断变化的浏览器环境。我会在适当的时候突出有用的资源和文档。

同样地,这本书不是关于游戏设计的书。我会教你如何构建,但不会告诉你构建什么。你学到的技能应该为你提供一个起点,让你能够实现自己的创意,或者开始处理其他人设计的项目。

如何使用本书

在本书中,我将帮助你创建构成Bubble Shooter游戏的 HTML、CSS 和 JavaScript 文件。在你完成教程时,应该始终保持index.html文件(在第一章中创建)至少在一个浏览器中打开。这样,你可以刷新页面,查看代码的修改如何改变游戏。

我鼓励你在本地开发 Web 服务器上运行Bubble Shooter,而不是从文件系统中查看,这样你就能像真实用户一样访问它,并查看它在移动设备上的显示效果。

注意

如果你不想手动输入示例代码,可以直接下载源代码(来自 buildanhtml5game.com/),然后在你正在阅读的章节的游戏文件中进行操作。

一旦你决定了如何加载Bubble Shooter文件进行测试,就可以跳转到第一章开始制作你的第一个游戏!

第一部分:使用 HTML、CSS 和 JavaScript 构建游戏

第一章。准备与设置

在本章中,我们将开始使用 HTML、CSS 和 JavaScript 开发一款完整的游戏。我们简单的泡泡射手游戏将展示一系列的开发技巧,但它不需要复杂的逻辑来控制游戏机制。游戏逻辑包括游戏元素之间的交互系统、由玩家的动作引发的事件、角色中的人工智能模拟等。开发复杂的游戏逻辑可能会很耗时,因此为了学习目的,我们将坚持使用一些基本原则,比如如何渲染图形和动画、响应用户输入以及播放声音。

我们将从用户界面和页面布局开始,然后加载脚本,最后添加一些基本的交互。在开发过程中,我们还将探索一些浏览器工具,这些工具在调试时非常有用,以及 Modernizr 和 jQuery——这两个主要的库将加速开发。我们将使用 Modernizr 来加载脚本并检测用户浏览器是否支持某个特性,而在处理 HTML 和 JavaScript 时,我们将使用 jQuery。

如果你在使用 HTML、CSS、JavaScript 和 jQuery 进行 Web 应用程序开发方面有经验,本章中的大部分代码你应该会很熟悉。我的目标是展示你如何通过相对少量的代码实现功能,以及如何轻松创建基本的交互元素。

游戏玩法

如果你曾经玩过泡泡龙Bust-a-MoveSnood或任何其他许多移动泡泡射击游戏,你已经知道了泡泡射手的基本机制。图 1-1 展示了完成后的游戏截图。

完成的泡泡射手游戏截图

图 1-1。完成的泡泡射手游戏截图

游戏的目标是清除屏幕顶部悬挂的所有泡泡。玩家通过鼠标瞄准并点击,从屏幕底部发射泡泡,射向顶部的泡泡,试图形成三颗或更多相同颜色的泡泡组合。一旦形成至少三个相同颜色的泡泡组合,组合中的所有泡泡都会爆炸,如图 1-2 所示。

如果发射的泡泡没有形成颜色匹配的组合,它将被添加到显示中,如图 1-3 所示。

蓝色泡泡射向组合,形成匹配,所有高亮的泡泡都会爆炸。

图 1-2。蓝色泡泡被发射到该组,形成配对,所有高亮显示的泡泡将会爆炸。

这里发射的蓝色泡泡不会导致上方的绿色组爆炸。相反,它会被添加到棋盘上。

图 1-3。这里发射的蓝色泡泡不会导致上方的绿色组爆炸。相反,它会被添加到棋盘上。

发射出的泡泡如果没有形成三个或更多的匹配组,就会卡在泡泡网格中。因为泡泡就像是都悬挂在最上排一样,如果一组泡泡在创建并移除一个匹配颜色的组后,无法找到回到顶部的连接,我们需要从屏幕上移除这些“孤立”泡泡。图 1-4 中展示了一个孤立泡泡的例子。

红色泡泡被孤立。我们不想让孤立的泡泡悬挂,所以我们需要一些逻辑来检测它们,并用动画把它们从屏幕上移除。

图 1-4。红色泡泡被孤立。我们不想让孤立的泡泡悬挂,所以我们需要一些逻辑来检测它们,并用动画把它们从屏幕上移除。

玩家只能发射有限数量的泡泡(图 1-1 显示为 70 个),他们必须在泡泡用完之前清除棋盘。每个关卡结束时,玩家根据爆破泡泡的数量得分,并进入下一关。游戏在玩家未能清除关卡时结束。

除了稍后我们将添加的几个增强功能外,这就是游戏的主要流程。

我们将使用 HTML、CSS 和 JavaScript 构建游戏机制——这些是创建许多简单游戏的核心工具,尤其是那些不需要精细像素操作的二维游戏。在Bubble Shooter中,我们实际上是在把一个圆形(泡泡)发射到另一个圆形的网格中(其他泡泡),然后要么像图 1-2 那样爆炸一个组,要么像图 1-3 那样把泡泡添加到棋盘上。游戏布局的要求相当简单,我们可以使用 CSS 和 JavaScript 来执行所有需要的动画。

我们将使用 HTML 和 CSS 构建用户界面,因为像大多数 HTML 游戏一样,气泡射手将利用浏览器擅长的任务,例如布局文本和渲染简单图形。在后续章节中,我们将探索使用canvas元素来显示游戏区域,但我首先会演示使用常规文档对象模型(DOM)开发所能实现的效果。

构建游戏

现在我们已经有了要创建的游戏概念,让我们将其分解为可管理的任务。为了创建气泡射手,我们需要解决一些高级挑战。具体来说,我们需要完成以下任务:

随机生成并渲染游戏棋盘

每个新关卡必须随机生成气泡网格并在屏幕上绘制出来。

计算气泡的发射角度和停止点

玩家将通过点击屏幕发射气泡。我们将计算发射气泡的角度,沿着该路径移动气泡,并在它碰到某个物体时停止,或者让它继续前进。

解决碰撞问题

当发射的气泡击中另一个气泡且未形成至少三个相同颜色的组合时,它将加入到棋盘上。否则,当它形成至少三个相同颜色气泡的组合时,它将炸裂所有与其接触的相同颜色气泡。如果发射的气泡确实爆炸了气泡,我们将检查是否产生了孤立的气泡,例如在图 1-4 中所示的那样。

跟踪得分和关卡

游戏在所有气泡被清除时结束。由于玩家只有有限数量的气泡可以发射,我们会跟踪已发射的气泡数量。我们还将添加一个得分系统,给玩家一个再次挑战的理由(例如打破高分)。

处理游戏结束和新关卡

如果玩家完成关卡,我们将通过某些界面元素标示出来,并给玩家一个选项继续进入下一关卡。切换关卡会清空棋盘并整理内部游戏状态,然后游戏重新开始。

开发和测试环境

让我们设置开发环境,确保我们拥有完成游戏所需的正确工具。要开始为 Web 开发游戏,你需要访问多个浏览器进行测试,并且需要可以编辑代码的软件。你还需要设置一个 Web 服务器以查看正在开发中的游戏。尽管你可以在本地运行气泡射手(只需打开其index.html文件),但你应该定期在尽可能模拟最终用户使用情况的环境中测试你的游戏。

注意

设置服务器的过程因操作系统而异。Apache Web 服务器(可在httpd.apache.org/下载)提供了良好的安装包和设置说明,适用于大多数系统配置。

网页浏览器测试

网络开发的一个规则是要在你预期的目标用户会使用的所有浏览器上进行测试。尽管这是发布软件时必不可少的步骤,但在开发过程中,你通常可以使用较小的浏览器子集来识别大部分潜在问题。你需要测试的浏览器列表在不断变化,但当你将游戏发布到 Web 时,接下来讨论的这些浏览器是必不可少的。

桌面浏览器

使用桌面 PC 或笔记本电脑的用户可能会在任何操作系统上使用不同的浏览器玩你的游戏,因此请确保至少在 Windows 和 OS X 上的最新版本的 Internet Explorer(IE)、Firefox、Chrome 和 Safari 中进行测试。根据你的目标用户群,你可能还需要测试早期版本的浏览器。

并不是每个人都会更新他们的网页浏览器,因此在为大量网页用户进行编码时,务必不要忽视可能使用早期版本的用户。一些版本的 IE 在同一操作系统中可能无法良好配合(由于 IE 与 Windows 的紧密集成),因此在进行测试时,你需要多个 Windows 安装环境,可以在不同的 PC 或虚拟机上进行。我强烈建议你安装并使用虚拟机软件,如 VMWare(www.vmware.com/)、VirtualBox(www.virtualbox.org/)或 Virtual PC(www.microsoft.com/download/;请在下载中心搜索)。虚拟机可以让你在常规操作系统内运行其他操作系统,实质上是在桌面上模拟一个完整的系统。预装了不同版本 IE 的虚拟机可以从www.modern.ie/en-us/virtualization-tools/下载。

因为 Firefox 现在会定期更新,所以你应该能够安全地在最新版本上测试你的游戏。早期版本对 HTML5 的支持不稳定,但后续版本通常不会有大的变化。Chrome 也会自动并定期更新,因此你不必担心版本问题,只需确保你使用的是最新版本。

当然,你还应该在 Mac 上至少测试一个版本的 Safari 浏览器。也可以在 Windows 中运行 OS X 虚拟机,尽管这种设置比在 Windows 内运行 Windows 或在 OS X 中运行 Windows 稍微复杂一些。有关在虚拟机应用程序中实现此设置的教程,可以在线找到。

移动浏览器

如果你在移动设备或平板电脑上部署,测试范围广泛的设备(iOS、Android 和 Windows 移动设备)和多个浏览器比以往任何时候都更加重要。对于基本的移动开发,访问一台 iOS 设备和一台 Android 设备可能足够进行测试,但当你考虑到更广泛的发布时,情况变得更加复杂。苹果的 iOS 版本在行为上有所不同,而 Android 在如此多的设备上有着不同的屏幕分辨率和硬件配置,你应该能够访问多个设备(可能通过有限的 beta 测试小组)进行测试。我们不会将 Bubble Shooter 打包发布到 Apple App Store 或 Google Play Store,但通过使用 HTML5 和 JavaScript 编写游戏,我们将制作一个可以在移动设备上玩的应用,无需额外的编码。

最终,由于 Android 平台的碎片化,单个开发者无法在每个设备上进行测试;因此,你可能会发现使用第三方测试服务更加可行。在 iOS 设备上的测试稍微简单一些,因为 Apple 控制其操作系统和设备规格,但 iPhone 和 iPad 的价格可能较高。当你将 Windows 平板电脑纳入考虑范围,并考虑到可以运行网页浏览器的平板电脑和其他便携设备的日益增加时,你会意识到,移动测试之战是难以取胜的。

在网页浏览器中调试

设置好测试浏览器后,你可以使用多个开发者工具来简化调试。每个浏览器都有自己的开发工具集,但幸运的是,它们都遵循类似的工作方式,提供检查页面 HTML 元素、添加断点和日志记录 JavaScript 的方法。学习如何访问你浏览器的开发者工具,并通过实验熟悉其功能。

所有浏览器调试工具都很有用,但在开发过程中,你可能最常使用 JavaScript 控制台。你将通过控制台与代码进行两种主要的互动:

使用 console.log 命令记录到控制台

调用 console.log 函数,控制台应显示你传递给该函数的内容。例如,console.log("Hello") 应该显示字符串 Hello。更好的是,当你使用 JavaScript 对象或数组调用 console.log 时,你会得到该对象内容的简要列出,可以用来探索整个对象树。

运行临时代码以检查变量状态

你可以将 JavaScript 代码输入到控制台中立即评估。输入 alert(1) 到控制台看看它如何工作。如果你的游戏代码公开暴露了对象属性,你可以利用此功能检查属性或触发方法。你甚至可以粘贴多行代码,以创建并在页面上下文中运行整个函数。

现在我们已经准备好了一些工具,让我们开始构建游戏。我们将从设置基本代码并实现开始界面用户界面开始。

布局游戏屏幕

在我们编写动画和游戏玩法的有趣部分之前,首先需要布局用户界面。我们将使用 HTML 和 CSS 来放置主要的界面元素;游戏屏幕将包含三个主要区域,如图 1-5 所示。

游戏屏幕的各个部分

图 1-5. 游戏屏幕的各个部分

在游戏屏幕的顶部,您可以看到状态栏 ➊,它将显示分数和关卡信息。接下来的部分(也是最大的部分)包含游戏区域 ➋,这里将包含所有气泡。游戏区域也是实际游戏玩法发生的地方。底部的页脚 ➌ 框架环绕着游戏区域。

现在,让我们布局这三个Bubble Shooter组件。

使用 HTML 和 CSS 创建面板

使用简单的 HTML 和 CSS 来布局游戏屏幕是创建这三个面板并定义游戏发生位置的最简单方法。这里使用的方法和技巧与构建常规网站或 Web 应用程序时使用的是一样的。

我们将首先为整个页面创建一个包装div。由于div标签没有语义意义,我们将其用作页面上的一个分区。首先,在您的 Web 服务器根目录下创建一个新的文件夹,用于构建游戏,并命名为bubbleshoot。游戏运行所需的每个文件都将存储在这个文件夹或其子目录中。接下来,创建一个名为index.html的新文件,并添加以下代码:

index.html

  <!DOCTYPE HTML>
  <html lang="en-US">
    <head>
      <meta charset="utf8">
      <title>Bubble Shooter</title>
    </head>
    <body>
➊    <div id="page">
      </div>
    </body>
  </html>

整个游戏将在这个单一的 HTML 页面中运行,"page" div ➊将限制游戏发生的区域。如果我们需要将游戏居中或调整它以适应不规则的屏幕纵横比,只需要改变包装元素的位置。

注意

许多 HTML 标签在 HTML5 中已简化,相较于版本 3 到 4 和 XHTML 的严格性。例如,文档类型声明现在大大简化,因为许多标签都已分配默认类型。<script>标签实际上在 HTML5 中默认就是 JavaScript,这就是为什么我们在页面中不需要指定type="text/javascript"language="javascript"的原因。

接下来,我们将创建三个新的div元素,每个元素对应一个页面部分,并将它们放置在我们的页面div内:

<div id="page">
  **<div id="top_bar"></div>**
  **<div id="game"></div>**
  **<div id="footer_bar"></div>**
</div>

现在,我们需要为页面和刚才添加的三个部分分配一些 CSS。

在游戏文件夹中创建一个名为* _css 的文件夹,用于存放我们将用于游戏的所有样式表。在_css文件夹中,创建一个名为main.css*的新文件,并包含以下代码:

main.css

  body
  {
    margin: 0;
  }
  #page
  {
    position: absolute;
    left: 0;
    top: 0;
    width: 1000px;
➊  height: 738px;
  }
  #top_bar
  {
    position: absolute;
    left: 0;
    top: 0;
    width: 1000px;
➋  height: 70px;
    background-color: #369;
    color: #fff;
  }
  #game
  {
    position: absolute;
    left: 0px;
    top: 70px;
    width: 1000px;
➌  height: 620px;
    background-color: #fff;
    clip: auto;
➍  overflow: hidden;
  }
  #footer_bar
  {
    position: absolute;
    left: 0;
    top: 690px;
    width: 1000px;
➎  height: 48px;
    background-color: #369;
  }

我们将顶部横幅的高度设置为 70 像素 ➋,底部横幅的高度设置为 48 像素 ➎。我们希望游戏适配标准显示器尺寸,因此我们将整个游戏区域的高度设置为 620 像素 ➌,使得总页面高度为 738 像素 ➊,这应该适配 1024×768 的显示分辨率,甚至还能容纳浏览器任务栏。

尺寸:流式布局 vs. 固定布局

为了保持游戏简单,我在 CSS 中使用了 1000 像素的固定宽度,这应该为大多数桌面和移动显示器提供足够的屏幕区域。通常,屏幕尺寸的情况更为复杂;尤其是移动设备的像素尺寸和长宽比有很大的差异。然而,我们希望专注于开发原则,而不是设计决策,1000 像素应该足够用于原型游戏。

这些值设置了整个可用显示区域的大小和位置。此外,请注意,gameoverflow:设置为hidden ➍,这意味着游戏中的泡泡永远不会意外地显示在页眉或页脚上。

为了链接 CSS 文件,我们将在 HTML 头部添加main.css的文件链接:

index.html

<head>
  <meta charset="utf8">
  <title>Bubble Shooter</title>
  **<link href="_css/main.css" rel="stylesheet">**
</head>

现在我们已经使用 HTML 和 CSS 创建了泡泡射手的基本结构,接下来在浏览器中加载页面并保持打开状态。此时尚未有交互,因此接下来我们将添加基本的交互功能,比如开始游戏对话框,然后再处理游戏逻辑。第一步是设置代码结构。

代码结构

让我们从高层次了解一下游戏和界面的主要概念,这将指导我们如何结构化代码。由于需要实现多个重要元素,我们将以类似于你可能熟悉的 Model/View/Controller(MVC)原则的方式结构化代码。如果 MVC 对你来说是新的,这里是基本的设置:

  • 模型由数据组成,并维护应用程序的状态。在 Web 应用程序中,这可能是用户的详细信息或购物车内容等。

  • 视图负责渲染屏幕上的内容并拦截用户输入。对于 Web 应用程序,这通常是 HTML 输出。例如,视图可能会从模型中读取在线购物车的内容,并将这些项目以列表的形式显示出来。

  • 控制器管理逻辑和处理。例如,点击视图中的某个项目时,会向控制器发送消息,要求其向购物车模型中添加新项目。

通过一些修改,这个 MVC 原则将适用于结构化泡泡射手

游戏控制器

游戏控制器将跟踪游戏状态,并充当导演,响应用户操作并确定结果。

游戏控制器类似于 MVC 系统中的控制器;它将运行游戏并管理所有功能。在一个更复杂的游戏中,单一控制器会变得过于庞大和复杂,无法处理所有任务,因为代码会集中在一个地方,并且一组代码会有太多责任,导致代码更容易出现难以发现的 bug。在这种情况下,我们可能需要进一步细分任务。幸运的是,Bubble Shooter 游戏非常简单,使用一个控制器来管理所有任务应该是可行的。

用户界面代码

游戏需要为用户展示各种信息,包括得分更新、关卡结束画面等。游戏控制器不会处理这些任务,而是会指示一组用户界面函数来控制用户界面元素的显示和消失方式。

你可以将大部分 UI 代码放入游戏控制器中,但你往往会写出与游戏逻辑一样多的动画和 UI 代码,因此最好将这些代码分开以提高可读性。通常,如果你没有以某种方式改变游戏的状态,而是管理显示中的某个功能,你应该在 UI 代码中处理这些任务。

游戏元素作为对象

我们将把一些游戏元素编写为对象,包括气泡和游戏板。原因是我们将有一些属性——例如气泡的 xy 坐标——以及需要应用的方法,例如气泡爆炸。按照面向对象编程的惯例,我们会将这些对象拆分成不同的类,以便代码结构与制作游戏所涉及的概念元素相对应。

游戏的对象将与 MVC 网络模式中的模型概念紧密对齐。对象将具有属性和状态,但它们不应该与显示界面交互或做出重要的游戏决策。

添加第一个脚本

我们将使用Modernizr,一个 JavaScript 库,来加载游戏所需的所有 JavaScript 文件,例如前面提到的游戏控制器和 UI 类。使用 Modernizr 相较于使用常规的 <script> 标签有一些优势,我将在本章稍后解释这些优势。Modernizr 还有其他有用的功能,但我们将首先加载所需的脚本文件。

Modernizr 和 jQuery 库

为了在游戏开发过程中简化一些常见任务,我们将大量依赖两个库。这两个库解决了许多跨浏览器问题,并提供了一套简单且一致的高级功能。

Modernizr 会加载脚本并检测浏览器中是否支持某个特定功能。例如,我们可以写一段代码来检测是否支持 canvas 元素。手动编码的话,你需要在 DOM 中创建一个 canvas 节点,然后检查它是否支持某个特定方法。在这个例子中,我们将使用 canvas 元素的 getContext 方法,它在所有支持 canvas 的地方都有支持,尽管你可以尝试任何你喜欢的 canvas 方法:

var element = document.createElement("canvas");
var canvasSupported = !!element.getContext;

使用 Modernizr,我们不需要做太多工作。我们可以简单地写:

var canvasSupported = Modernizr.canvas;

Modernizr 对象包含一组属性,这些属性的值在加载时被设置为 truefalse,具体取决于浏览器是否支持某个特定功能。因此,变量 canvasSupported 现在应该包含 truefalse,取决于 Modernizr.canvas 的值。使用 Modernizr 检测功能非常有帮助,因为如果浏览器改变了某个功能的实现方式,Modernizr 很可能会比你在代码中检测并实现更快地收到新的检测例程。

jQuery 还提供了有用的简写函数,但这些主要涉及检测和响应事件,发起异步 JavaScript 和 XML (AJAX) 请求与服务器通信,或者访问并操作浏览器 DOM 中的 HTML 元素。

DOM 是浏览器对 HTML 文档的内部组织结构,我们将主要使用 jQuery 的 DOM 访问方法来简化大部分动画代码。DOM 提供了一种方法,让你能够通过将 HTML 中的每个元素暴露为 DOM 节点来操作 HTML 结构。为了操作 DOM,我们首先使用 JavaScript 选择一个节点。然后我们可以改变它的一个或多个属性,常规的 JavaScript 使得这一步骤变得简单且直观。但是,使用 jQuery 可以使代码更容易按预期工作,而无需编写代码来处理那些实现各自功能不同的浏览器。

jQuery 最简单的应用例子是选择一个具有 ID 的 DOM 节点,比如我们创建的 game div。在常规 JavaScript 中,我们会这样写:

var element = document.getElementById("game");

这行代码获取一个单一的 DOM 元素,它将拥有各种属性,例如 CSS 格式和允许进行查询的方法。例如,element.getAttribute("id") 将返回字符串 game

jQuery 提供了一种方法,将这些功能及更多的功能封装在更简洁、更紧凑的语法中。为了用 jQuery 实现与前面代码相同的结果,我们使用 jQuery 选择器。选择器是用于选择 DOM 中节点的语法,其格式——包括点符号和使用 # 来选择唯一元素——借鉴了 CSS 选择器。jQuery 选择器返回的值不是 DOM 节点,而是包含对 DOM 节点引用的自定义对象,并且附带了一系列其他方法。使用 jQuery 选择器实现 document.getElementById("game").getAttribute("id") 的等效写法是 $("#game").attr("id")

选择器是 jQuery 的核心概念,到本书结束时,你会非常熟悉如何使用它们。几乎所有的 jQuery 都是用来操作 DOM 元素的,因此 jQuery 的调用几乎总是指定要更改的元素或元素集,这就是选择器的作用。它们让你能够根据一系列因素选择 HTML 节点集,例如以下几种:

  • 用于选择单个元素的唯一 ID。

  • 一个 CSS 类,用于选择所有具有该类的 DOM 元素。

  • 定义节点的标签(如 divimgspan 等),这可以让你例如选择页面上的所有图片。

  • 许多其他选项,包括前面列表中项的组合、元素在列表中的位置、父子关系,或者几乎任何你能用来遍历 DOM 的方式。

调用 $ 返回的 jQuery 对象允许你操作 DOM 对象。

因此,在 jQuery 中,document.getElementById 被简化为

var element = jQuery("#game").get(0);

我们需要调用 .get(0) 函数,从 jQuery 对象中获取 DOM 对象,尽管通常来说,操作 jQuery 对象比直接操作 DOM 对象更有用。这个调用可以进一步简化为

var element = $("#game").get(0);

$ 被定义为 jQuery 函数的别名,根据传入的参数,它可以执行多个不同的任务。对于选择器,我们传递一个字符串值(在这个例子中是 "#game")给 jQuery。像在 CSS 中一样,井号符号告诉 jQuery 我们要通过 ID 选择单个元素。值 $("#game") 返回包含指向 DOM 节点引用的 jQuery 对象。

你可以使用 jQuery 选择器来检索多个 DOM 节点,这些节点内部会作为一个数组存储。如果你想访问某个 DOM 节点,可以通过在 jQuery 对象上调用 .get(n) 来检索查询返回的第n个元素。由于我们只有一个 ID 为 game 的元素,我们需要获取第一个(零索引)元素,可以通过在 $("#game") 后加上 .get(0) 来实现。

在这个简单的例子中,我们并没有节省太多的代码,但更重要的是,我们可以使用 jQuery 从选择查询返回的对象,通过跨浏览器的方法来简化 DOM 操作的繁琐工作。

jQuery 对象让我们可以查询 DOM 节点的各种 CSS 属性,以下是一些示例:

var top = $("#game").css("top");
var width = $("#game").width();
var divs = $("div");

前两行分别查询游戏 divtop 位置和宽度,最后一行是一个选择器,返回一个包含页面所有 div 标签的 jQuery 对象。jQuery 是一个强大的库,尽管我们在游戏中会用到它的许多功能,但本书的范围并不包括详细介绍它。jQuery 的文档可以在 api.jquery.com/ 中找到,提供了对其工作原理的深入了解。

添加 Modernizr 库

要开始使用 Modernizr,可以从其官方网站下载(modernizr.com/download/)。Modernizr 允许你选择单独的功能进行下载,以避免浪费带宽下载那些你永远不会使用的代码。我们需要一些特定的功能,所以确保在下载页面选择以下选项:

  • CSS 过渡(在 CSS3 中)

  • Canvas 和 HTML5 音频(在 HTML5 下)

  • Modernizr.load(在额外功能下)

  • Modernizr.prefixed(在扩展性下)

然后,点击 生成下载

在游戏文件夹中创建一个名为 _js 的新文件夹,并将文件保存为 modernizr.js。同时,将该文件添加到 HTML 文档中,如下所示:

index.html

<head>
  <meta charset="utf8">
  <title>Bubble Shooter</title>
  <link href="_css/main.css" rel="stylesheet">
  **<script src="_js/modernizr.js"></script>**
</head>

现在,我们已经用 <script> 标签添加了 Modernizr,接下来将使用它来加载其余的 JavaScript 游戏文件。

使用 Modernizr 加载脚本

我们之所以不直接在文档中添加 <script> 标签,而是使用 Modernizr 加载脚本,主要有两个原因。首先,我们可以在脚本加载后立即触发函数运行,而不是等到整个页面(包括 HTML 和图片)加载完成后才运行。第二,Modernizr 允许我们基于条件加载脚本(例如,如果满足这个条件,加载这个脚本),而不需要编写大量代码。

注意

Modernizr 实际上使用了另一个名为 yepnope.js 的库来实现其脚本加载功能。你可以在 yepnopejs.com/ 了解更多关于这个库的信息。

一个简单的例子是从谷歌的内容分发网络(CDN)加载 jQuery,以加速加载时间。使用第三方 CDN 的潜在缺陷在于,CDN 可能无法使用或无法访问,或者更可能的是,你自己的服务器可能无法使用。然而,依赖一个你无法控制的服务从来都不是一个好主意。幸运的是,Modernizr 允许你在加载过程中添加测试,如果该测试失败,可以调用备份功能。因此,我们可以尝试从 CDN 加载文件,如果不行,再加载本地版本。

为什么使用谷歌托管的 jQuery?

尽管依赖他人的服务器获取关键文件可能看起来有些奇怪,但使用谷歌版的 jQuery 具有一些优势,我说的不仅仅是节省费用或在创建流行游戏时使用别人带宽的问题。

一个优点是,由于该文件来自 Google 的内容分发网络,用户几乎总是从比你的服务器更接近他们的服务器下载该文件。

另一个优点是,由于该文件来自与你的游戏托管服务器不同的服务器,用户实际上可以更快地下载它。浏览器通常会限制单个服务器的连接数量,因此即使带宽充足,文件也需要排队等待下载。通过将文件托管在不同的服务器上,可以增加并行下载的文件数量,从而减少下载时间。

另一个优点是,其他网站也在使用相同的 jQuery 副本;因此,如果用户最近访问过这些网站,他们很可能已经将该文件缓存到浏览器中,根本不需要重新下载文件!

jquery.com/download/ 下载最新的 jQuery 版本并将其放入 _js 文件夹。然后,在关闭 </head> 标签之前,添加以下加粗的代码块。确保将 URL 中的 jQuery 版本号更改为你下载的版本。

index.html

  <head>
    <meta charset="utf8">
    <title>Bubble Shooter</title>
    <link href="_css/main.css" rel="stylesheet">
    <script src="_js/modernizr.js"></script>
    **<script>**
➊  **Modernizr.load({**
      **load: "//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.js",**
➋      **complete: function () {**
➌        **if(!window.jQuery){**
➍          **Modernizr.load("_js/jquery-1.8.2.min.js");**
        **}**
      **}**
    **});**
    **</script>**
  </head>

这个示例展示了 Modernizr 提供的紧凑型脚本加载方式。简而言之,它会尝试从 Google CDN ➊ 加载 jQuery。当加载完成后 ➋(无论文件是否成功加载或脚本加载失败),complete 属性的函数调用会检查 window.jQuery ➌ 是否存在,如果该对象不存在,则从本地文件夹加载 jQuery 库 ➍。

调用 Modernizr.load 时使用两种不同参数集的原因是,该文件可以接受以下几种类型的参数:一个文件(作为字符串),一个包含名称/值对的对象,或者一个包含字符串或对象集合的数组。因此,我们可以通过一次 Modernizr.load 调用加载多个文件。在第一次调用 ➊ 中,我们传入一个包含 loadcomplete 属性的对象。(Modernizr 网站文档中记录了其他可以使用的属性。)在第二次调用 ➍ 中,我们只传入一个字符串。此行

**Modernizr.load("_js/jquery-1.8.2.min.js");**

等同于编写

**Modernizr.load({load : "_js/jquery-1.8.2.min.js"});**

第一个版本使用文件名字符串作为便捷的简写,用于仅加载该文件,而无需其他配置选项。

我们还希望通过 Modernizr 加载游戏脚本。在 _js 文件夹中创建一个名为 game.js 的新文件。要将新文件添加到 .load 中,请将第一个 Modernizr.load 调用用数组括起来,并添加一个新条目,如下所示(加粗):

index.html

Modernizr.load(**[**{
    load: "//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.js",
    complete: function () {
      if(!window.jQuery){
        Modernizr.load("_js/jquery-1.8.2.min.js");
      }
    },
  },
  **{**
    **load: "_js/game.js"**
  **}]**);

我们可以在任何时候继续将新文件添加到 Modernizr.load 调用中,作为数组中的新元素,直到加载 game.js 为止。

Modernizr 将加载 game.js,但该文件目前没有任何代码。下一步是设置主游戏控制器类,并在加载完成后运行它。

模块化 JavaScript

为了最小化全局变量以及潜在的变量名冲突问题,我们将采用模块化的方法来编写 JavaScript 代码。使用 模块 是一种将游戏的所有对象和变量封装在一个包含命名空间中的方式。这个命名空间将被命名为 BubbleShoot。一个名为 Game 的类将被包含在 BubbleShoot.Game 模块中,并可以在应用程序的任何地方通过写 BubbleShoot.Game 来访问。这个命名空间是一个保障:如果我们在开发后期添加了另一个 JavaScript 库,该库也有一个名为 Game 的变量,它们将能够同时存在而不会发生冲突。

我们将从游戏模块开始,它将运行游戏的大部分功能。将以下代码输入到 game.js 中:

game.js

➊ var BubbleShoot = window.BubbleShoot || {};
➋ BubbleShoot.Game = (function($){
➌   var Game = function(){};
➍   return Game;
➎ })(jQuery);

首先,我们检查对象 BubbleShoot 是否存在 ➊。将代码命名为 BubbleShoot.Game 大致相当于在 Java 或 C# 等语言中使用命名空间。所有的类都将作为 BubbleShoot 对象的属性。像 Bubble Shooter 这样的小型游戏中命名冲突的可能性不大,但在更大的项目中可能会成为问题。如果 window.BubbleShoot 不存在,它将被创建为空对象。我们将在每个类文件的顶部包含这行代码,这样就不需要考虑脚本加载的顺序。

下一行代码定义了 BubbleShoot.Game ➋。这种结构——一个括号内的函数——如果你不熟悉它,可能会觉得有些奇怪,但它是编写 JavaScript 模块时常用的一种方法。

该结构使用了 立即执行函数表达式(IIFE),它是一段创建并立即运行的函数代码。通常,你会将返回的结果赋值给一个变量。它的好处是,这个函数块在 JavaScript 中创建了一个变量作用域,这意味着任何在其中创建的变量都不会污染全局作用域。

变量声明包含一个函数定义,后面跟着圆括号 ➎。函数被创建、运行并立即销毁,但在销毁之前会返回它的内容 ➍,这些内容将是第 ➌ 步创建的 Game 对象。用括号包裹的函数调用 ➋ 位于新作用域内。一旦这个函数执行完毕,我们可以从全局作用域访问 Game 类,形式为 BubbleShoot.Game

现在我们有了一个类的框架,需要添加一些有用的代码来运行。我们从连接一个“新游戏”按钮开始。在 index.html 页面 div 中添加以下内容:

index.html

  <div id="page">
    <div id="top_bar"></div>
    <div id="game"></div>
    <div id="footer_bar"></div>
➊    **<div id="start_game" class="dialog">**
➋      **<div id="start_game_message">**
        **<h2>Start a new game</h2>**
      **</div>**
➌    **<div class="but_start_game button">**
        **New Game**
      **</div>**
    **</div>**
  </div>

新的 div 元素将创建一个对话框,在玩家开始游戏之前显示信息 ➊。对话框将包含一个带有消息的标题 ➋ 和一个“新游戏”按钮 ➌。我们仍然需要为它们在 main.css 的末尾添加一些样式,现在就来做吧。

main.css

.dialog
{
  position: absolute;
  left: 300px;
  top: 110px;
  height: 460px;
  width: 320px;
  background-color: #369;
  border-radius: 30px;
  border: 2px solid #99f;
  padding: 20px 50px;
  color: #fff;
  text-align: center;
  display: none;
}
.dialog h2
{
  font-size: 28px;
  color: #fff;
  margin: 20px 0 20px;
}
.but_start_game
{
  position: absolute;
  left: 100px;
  top: 360px;
  height: 60px;
  width: 200px;
  background-color: #f00;
  cursor: pointer;
  border-radius: 15px;
  border: 2px solid #f66;
  font-size: 28px;
  line-height: 60px;
  font-weight: bold;
  text-shadow: 0px 1px 1px #f99;
}
.but_start_game:hover
{
  background-color: #f33;
}
#start_game
{
  display: block;
}

main.css 中设置好样式后,重新加载页面以查看对话框。但请注意,目前玩家还无法移除它。这个功能将在 Game 类中实现。

接下来,我们需要在页面加载完成后运行一些代码,以便为“新游戏”按钮绑定事件处理程序。一个函数将在页面加载后初始化对象并设置游戏,另一个函数将在每次开始新游戏时执行。请对game.js进行以下更改:

game.js

  BubbleShoot.Game = (function($){
    var Game = function(){
➊    **this.init = function(){**
➋      **$(".but_start_game").bind("click",startGame);**
      **};**
➌    **var startGame = function(){**
      **};**
    };
    return Game;
  })(jQuery);

这段新代码设置了一个公共方法,叫做init ➊,以及一个私有方法,叫做startGame ➌。init方法是公共的,因为它作为Game对象的属性进行附加。在init内部,我们为“新游戏”按钮 ➋ 添加了一个叫做bind的 jQuery 事件处理程序,当按钮被点击时,它将调用startGame函数。

我们知道 IIFE 的生命周期很短,并且它不会作为任何对象的属性附加,然而startGame函数可以在这里被调用。原因在于 JavaScript 中的一个特性,叫做闭包。闭包意味着一个变量存在于定义它的代码块中,并且在该代码块内部持续存在。因此,startGame函数可以在Game中的其他函数内使用,包括init,但不能被该作用域外的任何 JavaScript 访问。

我们希望在页面加载后调用init,因此在index.html中,我们将在Modernizr.load中添加一个complete回调,当game.js加载完成后调用:

index.html

  Modernizr.load([{
      load: "//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.js",
      complete: function(){
        if(!window.jQuery){
          Modernizr.load("_js/jquery-1.8.2.min.js");
        }
      }
    },
    {
      load: "_js/game.js",
      **complete: function(){**
➊      **$(function(){**
➋        **var game = new BubbleShoot.Game();**
➋        **game.init();**
        **})**
      **}**
    }]};

回顾一下,$函数 ➊ 是jQuery函数的简写,它可以根据传入的内容执行多种任务。之前,我们传入了一个字符串(#game),jQuery 将其解释为一个选择器。在这种情况下,我们传入的是一个函数,jQuery 会将其存储,并在 DOM 准备好可以被操作时执行。

这段 jQuery 功能非常有用,特别是对于一个游戏,因为从这一点开始我们知道即使游戏的其他资源(如图像和声音)尚未加载完毕,我们也可以安全地使用 JavaScript 操作 DOM。传统上,客户端交互是通过等待window.onload事件触发来实现的,这表明 HTML 已经加载,DOM 已经准备好,所有的图像也已加载。然而,等待图像加载完成可能会导致用户长时间无法与页面进行交互。更好的选择是让用户在 DOM 准备好后就能与应用程序进行交互,而无需等待图像加载,这样可以提供更响应式的界面。但确定 DOM 何时准备好通常涉及每个浏览器厂商独特的代码,并且从一个浏览器版本到另一个版本可能会有所变化。jQuery 的$函数解决了浏览器之间的差异,让你在不必精确确定 DOM 何时准备好的情况下实现这种响应性。

回顾一下$函数。在它内部,我们创建了一个Game类的实例 ➋,然后调用了该类的公共init方法 ➌。根据我们对$函数的了解,我们在init中做的任何事情都应该在 jQuery 加载完毕且 DOM 准备好后执行。

现在我们有了一个Game的实例,将它的startGame函数绑定到新游戏按钮上。然而,startGame函数仍然什么也不做。让我们改进一下!

引入闭包

JavaScript 最强大的功能之一,闭包意味着在函数内定义的变量会存储在函数的作用域中。这些变量即使在函数退出后仍然存在于作用域内,只要 JavaScript 解释器认为它们仍然需要使用它们。保持在作用域中的原因可能包括事件处理程序在触发时需要某个值,或者是一个setTimeout调用在未来某个时刻需要访问某个变量。一个简单的例子可以帮助解释这一点,但为了更好地在自己的函数中使用闭包,值得深入了解这一概念。

以下示例展示了作用域在 JavaScript(以及许多其他语言)中的工作原理。此示例中的三个alert调用应该分别显示 1、2 和 1,因为函数内部的myVar值不会覆盖父作用域中的值。你可以从 JavaScript 控制台运行以下代码:

var myVar = 1;
alert(myVar);
function innerMyVar(){
  var myVar = 2;
  alert(myVar);
};
innerMyVar();
alert(myVar);

为了演示即使在函数执行时作用域依然保持不变,我们可以在innerMyVar函数内添加一个定时器:

var myVar = 1;
alert(myVar);
function innerMyVar(){
  var myVar = 2;
  setTimeout(function(){
    alert(myVar);
  },1000);
  myVar++;
};
innerMyVar();
alert(myVar);

这段代码应该显示 1、1 和 3 的警告框。innerMyVar的作用域保留了其中定义的myVar值,包括在定时器定义之后发生的增量。

你可以在 developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Closures/ 阅读更多关于作用域的内容。

用户界面和显示脚本

让我们创建一个类来处理用户界面和其他页面显示功能。在_js文件夹中创建一个名为ui.js的新文件,并添加以下代码:

ui.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.ui = (function($){
➊  var ui = {
      init : function(){
      },
➋    hideDialog : function(){
➌      $(".dialog").fadeOut(300);
      }
    };
    return ui;
  })(jQuery);

尽管这段代码内容不多,但请注意,UI 对象遵循与之前的Game类相同的模式。hideDialog函数 ➋ 包含一段简单的 jQuery 代码,能够使任何带有 CSS 类dialog的 HTML 元素渐隐 ➌。uiGame类模式的结构性不同之处在于,ui并没有创建一个类,而是直接创建了一个单一对象 ➊ 并将方法附加到该对象上。这种结构类似于其他语言中使用静态类的方式。

调用fadeOut时需要一个参数,指定淡出对话框所需的毫秒数。我们使用 300 的值,这足够快,不会拖慢用户体验,同时也不会太快,以至于用户无法注意到。fadeOut方法内建于 jQuery 中,但也有其他方式可以组合选择器并操作 DOM 元素。现在,我们快速回顾一下 jQuery 在fadeOut调用中实际做了什么:

  • 将 CSS 透明度减少一个小的固定值,并在 300 毫秒内循环执行。此时结束时,透明度应为零。

  • 将元素的display CSS 属性设置为none

我们本可以通过手动串联一些setTimeout调用来创建这个效果,但 jQuery 用fadeOut为我们处理了这个问题。使用 jQuery 在这里为我们节省了大量代码,因为像很多 CSS 属性一样,操作透明度在不同浏览器之间并不简单(例如,早期版本的 IE 使用滤镜而不是opacity属性)。

请注意,目前我们并没有做任何特别的 CSS3 或 HTML5 相关的事情。我们正在使用旧的 HTML 标签,并通过 JavaScript 循环操作相对较旧的 CSS 属性。书中的后续内容将教你是否应该以更现代的方式来做,但目前这些代码已经很好地完成了工作。在开发游戏时,你会意识到,很多代码在较早的浏览器中运行得和在较新的浏览器中一样好,除非你专门为 Canvas 渲染,否则 HTML5 游戏看起来和普通的 Web 应用程序类似。

现在我们需要通过将其添加到Modernizr.load调用中,将新创建的 UI 文件加载到index.html中:

index.html

  Modernizr.load([{
      load: "//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.js",
      complete: function(){
        if(!window.jQuery){
          Modernizr.load("_js/jquery-1.8.2.min.js");
        }
      }
    },
➊  **"_js/ui.js",**
    {
      load: "_js/game.js",
      complete: function(){
        $(function(){
          var game = new BubbleShoot.Game();
          game.init();
        })
      }
    }
  ]);

要将一个新的.js文件添加到加载调用➊中,只需将你的脚本文件的 URL 作为额外的数组项添加到ui.js之后,game.js之前。我们需要将每个新创建的脚本文件添加到index.html,所以记住这个过程。

要在点击“新游戏”按钮时调用hideDialog,请在game.js中添加以下

game.js

  BubbleShoot.Game = (function($){
    var Game = function(){
      this.init = function(){
➊      $(".but_start_game").bind("click",startGame);
      };
      var startGame = function(){
➋      **$(".but_start_game").unbind("click");**
➌      **BubbleShoot.ui.hideDialog();**
      };
    };
    return Game;
  })(jQuery);

在 jQuery 中使用bind方法➊是一种跨浏览器的添加事件处理程序的方式。这个方法将一个函数绑定到一个对象,当事件发生时触发。在这个游戏应用中,当用户点击“新游戏”按钮时,触发该事件,进而调用startGame函数。

请注意,我们在按钮点击时解绑事件➋,以防止在按钮淡出时注册双击事件,从而导致尝试启动两次游戏。如果你重新加载页面并点击“新游戏”按钮,hideDialog函数➌会使对话框消失。

Bubble Shooter游戏目前功能较少,但至少现在我们已经有了一些结构,可以在其中添加代码。

总结

现在我们已经有了构建游戏的基础。Modernizr 正在加载文件,我们可以轻松地在创建更多类和函数时继续添加任务。当 DOM 加载完成时,会创建Game类的实例,点击按钮即可开始游戏。

在下一章中,我们将为气泡对象创建第一个精灵,构成游戏的核心,并学习如何在屏幕上为精灵添加动画效果。

进一步练习

  1. 对话框是通过 jQuery 的fadeOut函数隐藏的,但你可以应用其他效果将对话框从屏幕中移除。例如,可以尝试使用slideUphide代替fadeOut。或者,可以先将对话框放在屏幕外,再通过animate函数将其移入屏幕。

  2. 对话框以及头部和脚部的颜色和样式非常简单。可以改变这些区域的颜色(甚至可以尝试使用图形),直到找到自己喜欢的组合。

  3. 学会使用浏览器的调试工具。你很可能可以访问断点、监视变量和变量堆栈,因此请阅读相关内容并进行实验。现在熟悉这些工具,未来在调试时能节省很多时间。尝试在game.js中的initstartGame函数内添加断点,并在代码运行时进行追踪。

第二章. 使用 jQuery 和 CSS 实现精灵动画

在本章中,我们将深入探讨如何在屏幕上移动精灵。动画是游戏开发中最常见的任务之一,你将在动画一个简单游戏时学到的原理适用于大多数游戏类型。

尽管关于 HTML5 游戏的讨论大多集中在canvas元素上,但你完全可以使用更传统的 HTML、CSS 和 JavaScript 技术来实现许多游戏,这也是本章的重点。这些技术本身就是有用的游戏开发经验,而且当我们后面深入研究canvas元素时,它们也会带来好处。使用 HTML、JavaScript 和 CSS 技术开发的游戏,通常被称为基于 DOM 的游戏,它们具有更广泛的浏览器兼容性。一些仍在使用的旧版浏览器不支持canvas,也很可能不支持 CSS3 的变换和过渡效果;因此,我们将使用较旧的 CSS 特性。

泡泡射手游戏的核心机制当然是射击气泡,而玩家发射的每个气泡都会触发气泡爆破效果。我们将从根据用户输入(鼠标点击)来移动已发射的气泡开始。

首先,我们需要一种方法将气泡从起始点 A 移动到终点 B,并且气泡需要沿直线以恒定的速度移动。其次,我们需要准确确定点 A 和点 B 的位置。因为玩家总是从相同的位置发射气泡,起始坐标(点 A)对于每个新气泡都是相同的。点 B 则是用户在发射气泡时鼠标点击的坐标,因此我们必须获取这些坐标。首先,我们将实现从 A 到 B 的移动。

在最终的游戏中,气泡不会在到达点击坐标时停止,而是会继续移动,直到与另一个气泡发生碰撞或移出屏幕边缘。碰撞的处理将在稍后,当我们更全面地开发游戏展示时再进行。

当我们从一个点移动到另一个点时,就可以推算气泡的路径,超出用户点击的位置,并继续沿相同方向推动气泡。为了找到这条路径,我们需要根据点 A 和点 B 的相对位置计算发射角度,如图 2-1 所示。

沿向量移动气泡

图 2-1. 沿向量移动气泡

给定这个发射角度,我们可以将气泡发射到一个特定的方向,直到所需的距离。之后,我们可以通过确定碰撞来计算气泡需要移动多远。现在,我们暂时将所需的距离定义为足够远的一个点,使得气泡能够移出屏幕。

CSS 精灵原理

精灵是一个二维的游戏元素,它是更大场景的一部分,但可以独立移动,而不影响背景数据。目前,点 A 的气泡是唯一的精灵。

在这种基于 DOM 的方法中,最简单的情况下,精灵是一个带有 CSS 样式的 HTML 块(通常是一组 div 标签)。由于浏览器渲染 HTML 的方式,移动精灵而不改变屏幕上的其他部分是非常容易的。使用 CSS 进行绝对定位的 HTML 元素会独立于周围的 HTML 元素进行渲染。浏览器将所有对象绘制到屏幕上并处理图层和重叠。如果我们移除一个对象,浏览器知道它需要显示下面的内容。这个 HTML 和 CSS 精灵操作的特性,在 canvas 开发中并不完全适用,但正如我们在 第六章中学习 canvas 元素时看到的那样,它是使 DOM 游戏开发成为理想起点并成为快速原型制作游戏的一个极好工具的功能之一。

创建游戏板

Bubble Shooter 游戏中,所有气泡都将是精灵,这样我们就可以将它们作为自包含的元素移动到屏幕上。我们将很快通过创建一个气泡来创建第一个精灵,这个气泡将放置在显示区域内。但首先,我们需要为游戏板创建一个容器,在所有气泡动作发生的区域内。我们将这个容器放在一个名为 "board"div 中,因此在 index.html 中添加这个新的 div

index.html

<div id="game">
  **<div id="board"></div>**
</div>

接下来,我们将使用 CSS 来定位游戏板。游戏板将位于固定宽度的显示区域的中央,因此我们将创建一个宽度为 760 像素的板,并将其从 game div 的左边缘定位 120 像素,game div 被定位在窗口的左侧。在 main.css 中添加 #board 的定义,位于 #game 的定义之后:

main.css

body
{
  margin: 0;
}
*--snip--*
#game
{
--snip--
}
**#board**
**{**
  **position: absolute;**
  **left: 120px;**
  **top: 0;**
  **width: 760px;**
  **height: 620px;**
**}**

我们还需要一些 CSS 来描述气泡的起始位置、宽度和高度。玩家当前的气泡将位于游戏区域的底部中央,并且会是 50 像素的正方形。我们将为玩家当前准备发射的气泡分配 CSS 类 cur_bubble,并在样式表中定义其定位和外观。我们会将游戏元素放在自己的 CSS 文件中,这样我们就能轻松区分它们与各种用户界面元素,如对话框和按钮。

_css 目录下创建一个新文件,命名为 game.css,并将以下代码放入其中:

game.css

.bubble
{
  position: absolute;
  width: 50px;
  height: 50px;
}
.cur_bubble
{
  left: 360px;
  top: 470px;
}

每个气泡将放置在一个 50 像素的正方形内。我们可以将整个游戏区域完全填充气泡,但诀窍是提供一个大面积的游戏板,同时又不让游戏持续时间过长。经过一些试验和错误后,我选择使用 16 个气泡,这样应该能适应游戏区域的宽度,并且仍留有一些边距。

我们还需要将 game.css 链接到 HTML 页头中的样式表文件,因此在链接到 main.css 之后,添加该链接到 index.html 中:

index.html

<head>
  <meta charset="UTF-8" />
  <title>Bubble Shooter</title>
  <link href="_css/main.css" rel="stylesheet" />
  **<link href="_css/game.css" rel="stylesheet" />**

我们想要发射的气泡尚未显示在屏幕上,因此我们需要将一张图片添加到文件系统中,然后使用一些 CSS 来显示它。

添加精灵

图 2-2 展示了一个气泡的外观(未上色)。气泡的外观将作为背景图像渲染在板 div 元素中。

我们的第一个气泡精灵图

图 2-2. 我们的第一个气泡精灵图

我们将使用四种不同的气泡颜色,所以让我们同时制作所有四种颜色的气泡。任何四种颜色都可以,只要它们足够显眼。和其他资源一样,通常是图像和声音文件,我们会将有颜色的气泡存储在一个带下划线的文件夹中。我们将这个文件夹命名为* _img*。

为了加快加载时间并简化文件管理,我们将把所有四种气泡类型的图像放入一个单独的 PNG 文件中。你可以在图 2-3 中看到完整的图像。

包含四种气泡类型所有动画状态的单一图像文件

图 2-3. 包含四种气泡类型所有动画状态的单一图像文件

PNG 文件(bubble_sprite_sheet.png)不仅包含四种气泡的基本状态,还包含我们稍后会用到的气泡爆炸动画过程。标准的气泡图像显示在左列;三个爆炸动画阶段显示在第二列、第三列和第四列。由于我们有四种不同的气泡,我们将创建 CSS 定义,使我们通过上下移动背景图像的位置来显示我们想要的颜色。使用单一图像渲染多个精灵是我们使用 CSS 背景图像的原因,而不是直接将<img>标签放入 DOM 中;因此,浏览器只需要下载一个图像文件,这可以加快初始化时间。此外,爆炸的动画帧已经预加载,所以在游戏后续加载图像时,我们不应该遇到任何卡顿。

尽管我们使用了四种气泡颜色,但游戏并不需要知道具体的颜色——我们甚至可能以后改变颜色选择——但它确实需要一种方法来引用这些颜色。我们将把气泡类型编号从零到三来表示四种颜色。

我们可以使用.bubble的基础 CSS 类来设置所有气泡共有的属性,并在需要指定气泡类型(即设置颜色)时,向 HTML 元素添加额外的类。请按照以下方式修改game.css

game.css

.bubble
{
  position: absolute;
  width: 50px;
  height: 50px;
  **background-image: url("../_img/bubble_sprite_sheet.png");**
}
.cur_bubble
{
  left: 360px;
  top: 470px;
}
**.bubble_0**
**{**
  **background-position: 0 0;**
**}**
**.bubble_1**
**{**
  **background-position: 0 -50px;**
**}**
**.bubble_2**
**{**
  **background-position: 0 -100px;**
**}**
**.bubble_3**
**{**
  **background-position: 0 -150px;**
**}**

现在,当我们想渲染四个气泡时,我们只需要向div元素添加正确的类,background-position属性应该会显示出适当的图像。如果我们想将最后一种类型的气泡硬编码到 DOM 中,可以添加以下内容:

<div class="bubble bubble_3"></div>

第一种类型的气泡将是

<div class="bubble bubble_0"></div>

尽管我们目前已经在 CSS 中定义了气泡,但我们没有 HTML 来将其显示在屏幕上。我们不会将气泡硬编码,而是通过 JavaScript 生成它们。但在开始为气泡添加动画之前,我们需要先创建并渲染一个气泡,这是下一部分的重点。

动画与气泡类

由于气泡是游戏中的主要元素之一,我们将为其创建一个单独的 JavaScript 类。我们目前还不知道该类可能需要哪些所有属性,但对于每个需要在代码中操作的气泡对象,都将有一个屏幕上的元素进行显示;因此,我们将创建一个属性来引用它。我们将其命名为sprite属性,它将存储我们用来操作 DOM 元素的 jQuery 对象的引用。

将以下内容放在一个单独的文件中,命名为bubble.js,并将新文件添加到index.html中的Modernizr.load调用中,位于ui.js之后:

bubble.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Bubble = (function($){
    var Bubble = function(sprite){
      var that = this;
➊    this.getSprite = function(){ return sprite;};
    };
➋  Bubble.create = function(){
      var sprite = $(document.createElement("div"));
      sprite.addClass("bubble");
      sprite.addClass("bubble_0");
      var bubble = new Bubble(sprite);
      return bubble;
    };
    return Bubble;
  })(jQuery);

我们只有一个参数传递给构造函数,那就是引用将在Bubble.create函数 ➋调用中创建的 jQuery sprite对象。由于分配了bubble_0 CSS 类,该函数目前只创建一种类型的精灵。当前,类定义中只有一个方法 ➊,它返回sprite对象。当我们想要创建一个气泡时,我们不会直接调用BubbleShoot.Bubble,而是会调用BubbleShoot.Bubble.create。因此,我们可以确保气泡的所有组件都正确实例化,并最小化代码重复。

现在我们可以创建Bubble对象,且文档元素会同时创建。然而,气泡仍然不会成为可见的 DOM 的一部分,因为它尚未插入文档。为了处理这个问题,我们将在Game内部创建一个函数,用于创建新气泡并将 CSS 类cur_bubble添加到新创建的 DOM 元素中。

在游戏中的任何时刻,屏幕上只有一个准备好供玩家发射的气泡,因此我们将在Game内的一个变量中保留对它的引用,命名为curBubble。为了完成这一步气泡创建的工作,请将加粗的行添加到game.js中:

game.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
    var Game = function(){
➊    **var curBubble;**
      this.init = function(){
        $(".but_start_game").bind("click",startGame);
      };
      var startGame = function(){
        $(".but_start_game").unbind("click");
        BubbleShoot.ui.hideDialog();
➋      **curBubble = getNextBubble();**
      };
➌    **var getNextBubble = function(){**
➍      **var bubble = BubbleShoot.Bubble.create();**
➎      **bubble.getSprite().addClass("cur_bubble");**
➏      **$("#board").append(bubble.getSprite());**
        **return bubble;**
      **};**
    };
    return Game;
  })(jQuery);

Game定义的顶部,我们定义了curBubble ➊,它只会在Game对象的作用域内存在。这个空的变量在此声明,并在用户点击“新游戏”按钮时设置,该按钮会调用startGame。在这里,curBubble被设置为getNextBubble ➋返回的值。getNextBubble ➌调用BubbleShoot.Bubble.create ➍,该函数返回一个Bubble类的实例,并将 CSS 类cur_bubble ➎添加到 DOM 元素中。最后,DOM 元素被追加到板块div元素中 ➏。

重新加载页面并点击新游戏。在屏幕的底部中央,你应该看到一个气泡出现。这个气泡现在还不能移动,但我们将在下一部分通过添加一些简单的动画来改变这一点。

计算角度和方向

为了确定气泡发射的方向,我们需要找出用户点击时鼠标的位置。我们可以通过检查响应 click 事件的事件对象来做到这一点。Game 控制器需要知道发射气泡的角度以及游戏结果显示的内容。为了避免在控制器中添加界面代码,ui 对象将处理运动过程,步骤如下:

  1. 查找鼠标点击的坐标。

  2. 计算从气泡的起始点到点击点的向量。

  3. 延长该向量足够的长度,将气泡移动出游戏屏幕。

  4. 将气泡移动到向量的末端。

气泡轨迹的示例见于 图 2-1。

此时,运动过程假设气泡不会与任何东西碰撞,这是我们首先要解决的特性。

Game 函数定义中,创建 clickGameScreen 函数(紧跟在 getNextBubble 函数之后),并为 startGame 添加事件绑定,如下所示:

game.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
    var Game = function(){
      var curBubble;
      --*snip*--
      var startGame = function(){
        $(".but_start_game").unbind("click");
        BubbleShoot.ui.hideDialog();
        curBubble = getNextBubble();
        **$("#game").bind("click",clickGameScreen);**
      };
    *--snip--*
➊    **var clickGameScreen = function(e){**
        **var angle = BubbleShoot.ui.getBubbleAngle(curBubble.getSprite(),e);**
      **};**
    };
    return Game;
  })(jQuery);

函数 clickGameScreen ➊ 会响应用户点击屏幕时被调用。作为 jQuery 事件处理的一部分,它将接收一个事件对象 e,其中包含关于点击对象的有用数据,包括点击的坐标。此函数还会调用 BubbleShoot.ui.getBubbleAngle,该方法将使用事件对象的点击坐标计算气泡的发射角度。返回的值将是一个角度,单位为弧度,表示气泡相对于其垂直中心线的左侧或右侧。现在我们来编写这段代码。

ui.js 中,在 ui 对象的顶部添加以下常量,并在 hideDialog 之后添加新的方法:

ui.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.ui = (function($){
    var ui = {
➊    **BUBBLE_DIMS : 44,**
      init : function(){
      },
      hideDialog : function (){
        $(".dialog").fadeOut(300);
      },
      **getMouseCoords : function(e){**
➋      **var coords = {x : e.pageX, y : e.pageY};**
          **return coords;**
      **},**
      **getBubbleCoords : function(bubble){**
➌      **var bubbleCoords = bubble.position();**
        **bubbleCoords.left += ui.BUBBLE_DIMS/2;**
        **bubbleCoords.top += ui.BUBBLE_DIMS/2;**
        **return bubbleCoords;**
      **},**
      **getBubbleAngle : function(bubble,e){**
        **var mouseCoords = ui.getMouseCoords(e);**
        **var bubbleCoords = ui.getBubbleCoords(bubble);**
        **var gameCoords = $("#game").position();**
        **var boardLeft = 120;**
➎      **var angle = Math.atan((**➍**mouseCoords.x - bubbleCoords.left - boardLeft)**
          **/ (**➍**bubbleCoords.top + gameCoords.top - mouseCoords.y));**
➏      **if(mouseCoords.y > bubbleCoords.top + gameCoords.top){**
          **angle += Math.PI;**
        **}**
        **return angle;**
      **}**
    };
    return ui;
  })(jQuery);

BUBBLE_DIMS ➊ 是气泡精灵在 DOM 中的宽度(和高度)。这个常量使我们能够计算出元素中心的偏移量,这意味着我们可以转换为 CSS 使用的(top, left)坐标。在游戏编程中,当你改变一个物体的位置时,通常需要使用物体的中心坐标,而在渲染时,你将使用(top, left)坐标。

这段新代码通过从 jQuery 事件对象 e 中获取两个属性,来获取玩家鼠标点击的坐标 ➋。我们还需要起始气泡的坐标,所以接下来的方法 ➌ 将通过另一个 jQuery 方法来完成这个任务。得到这两个坐标对后,我们可以计算它们之间的相对 x/y 偏移量 ➍。接着,我们可以使用正切三角函数 ➎ 根据 x/y 偏移量来计算角度。如果点击发生在气泡的中心线下方 ➏,我们将角度加上 pi(即 180 度,但 JavaScript 的三角函数总是以弧度为单位),以便描述一个完整的圆。

为了计算角度,我们使用了一些三角函数,随着你制作游戏的过程,你会变得更加熟悉这些函数(如果你还不熟悉的话)。Math.atan方法检索相对于垂直方向的角度,正数表示右侧,负数表示左侧。返回的角度是一个弧度值,范围从负π到正π。

发射和动画化气泡

现在我们知道了发射气泡的角度,我们可以将其发射出屏幕。假设我们将气泡发射到 1000 像素远——这足以将其移出游戏区域——然后查看实际效果。

快速的三角函数复习

我们可以通过一些三角函数使用反正切函数来计算发射气泡的角度。在图 2-4 中,我们通过计算向量的 x 和 y 分量的反正切来得到角度。

手动计算发射角度

图 2-4. 手动计算发射角度

将以下代码行添加到game.js中的clickGameScreen

game.js

  var BubbleShoot = window.BubbleShoot || {};
    BubbleShoot.Game = (function($){
    var Game = function(){
      *--snip--*
      var clickGameScreen = function(e){
        var angle = BubbleShoot.ui.getBubbleAngle(curBubble.getSprite(),e);
        **var duration = 750;**
        **var distance = 1000;**
        **var distX = Math.sin(angle) * distance;**
        **var distY = Math.cos(angle) * distance;**
        **var bubbleCoords = BubbleShoot.ui.getBubbleCoords(curBubble.**
          **getSprite());**
        **var coords = {**
          **x : bubbleCoords.left + distX,**
          **y : bubbleCoords.top - distY**
        **};**
➊      **BubbleShoot.ui.fireBubble(**➋**curBubble,**➌**coords,**➍**duration);**
      };
    };
    return Game;
  })(jQuery);

新的代码设置了持续时间和总距离,然后计算沿着x轴和y轴的距离,从而给出距离起始点 1000 像素的坐标(coords),朝着鼠标点击的方向。

接下来,我们需要编写fireBubble函数➊,该函数接受bubble对象➋、一个目标坐标➌和一个持续时间➍作为参数。我们将其放入ui类中,因为它只处理屏幕上的运动,不会影响游戏状态。

ui.js中,紧接着getBubbleAngle方法后添加一个新方法:

ui.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.ui = (function($){
    var ui = {
      *--snip--*
      getBubbleAngle : function(bubble,e){
        *--snip--*
      },
      **fireBubble : function(bubble,coords,duration){**
➊      **bubble.getSprite().animate({**
➋          **left : coords.x - ui.BUBBLE_DIMS/2,**
            **top : coords.y - ui.BUBBLE_DIMS/2**
          **},**
          **{**
➌          **duration : duration,**
➍          **easing : "linear"**
          **});**
      **}**
    };
    return ui;
  })(jQuery);

fireBubble方法是一个 jQuery 调用,它通过 jQuery 的animate方法移动气泡。传递给该函数的坐标表示气泡需要停止的中心点。为了确保气泡到达正确的(top, left)坐标,fireBubble首先将接收到的坐标转换为对象的左上角坐标➊,这是 CSS 定位元素的方式。

最简单的动画方式是将精灵移动到屏幕上,分为两个步骤:➊将精灵放置在一个固定位置,➋在一段短时间后将其移动到新的位置。重复第二步,直到精灵到达目标位置。使用 DOM 操作时,我们只需要在每次移动时更改元素的 top 和 left CSS 属性,浏览器会自动处理其余部分。

我们可以通过两种方式实现这个动画。我们可以使用 JavaScript 动画,这需要我们手动在路径的每一步中移动精灵,或者我们可以使用 CSS3 过渡效果,在每帧中无需我们代码的参与来移动精灵。本章我们将重点介绍 JavaScript 方法,稍后我们会展示 CSS3 的实现方式。

与我们希望在 JavaScript 和 CSS 中实现的许多效果一样,我们可以让 jQuery 为我们做大部分工作。animate 方法提供了一种动画化数字 CSS 属性的方法,如 left 和 top 坐标。它计算起始值和结束值之间的差距,并在若干步中将属性的值从起始值变化到结束值。

注意

此方法不适用于非数字 CSS 属性,因为从起始值到结束值的转换无法轻松计算。例如,你不能使用 animate 来过渡背景颜色,起始值和结束值是十六进制对,因为在两种颜色之间插值并不是一个简单的计算。

animate 方法接受多个参数,包括以下内容:

  • CSS 属性 ➋。指定要动画化的属性。通常,这些是定位属性,如 topleft,但它们也可以是任何可以用像素单一整数维度定义的属性,包括 font-size、宽度、高度,甚至 border-widthmargin-left。(注意,像 margin: 0 10px 20px 10px 这样的简写定义包含了四个不同的值,因此它不能直接与 animate 配合使用,需要将其拆分成 margin-topmargin-rightmargin-bottommargin-left 四个部分。)

  • 持续时间 ➌。定义动画持续时间的长度,以毫秒为单位。此处的持续时间固定为 1 秒(1000 毫秒),以每秒 1000 像素的速度进行动画。气泡的移动距离将取决于游戏状态,特别是气泡可能与什么碰撞。但是,目前的持续时间应该适合那些被发射出屏幕的气泡。

  • 缓动 ➍。定义物体从起始状态到结束状态的过渡方式。缓动通常用于改变沿运动路径的加速和减速。例如,对于运动,linear 表示从起点到终点的速度是恒定的,而 swing 则在开始时有加速,结束时有减速。

你也可以将其他选项传递给animate,并且参考 jQuery 文档可以了解该函数的全部潜力。要触发气泡,我们只需要前面的参数。

重新加载页面并点击气泡上方的某个位置,气泡应该会朝那个方向飞去。这只会发生一次。你需要刷新页面才能再次看到它,但这无疑是一个开始。

总结

在本章中,你已经学会了如何使用 jQuery、HTML 和 CSS 技术进行简单的动画效果。现在,我们已经具备了基本的代码,可以在鼠标点击时使气泡在屏幕上移动,是时候开始完善这个游戏了。

在 第三章,我们将专注于绘制游戏棋盘、检测碰撞和消除气泡组。

进一步练习

  1. 如果你在游戏区域再次点击,气泡会重新出现在屏幕上。你如何禁用这个点击事件以防止它发生?

  2. .animate调用中,我们指定了easing : "linear"。试着使用"swing",并思考为什么这对Bubble Shooter来说可能不合适,但对于其他游戏可能是更好的动画方法。然后查看更多的 easing 设置,访问 api.jqueryui.com/easings/,看看你是否可以将其中的任何设置融入到代码中。

第三章 游戏逻辑

到这一点,我们已经创建了一个包含新游戏按钮和一个玩家可以发射的泡泡的介绍屏幕。在这一章中,我们将把泡泡射手变得更加完整,变成一个真正的游戏。你将学习如何绘制游戏面板并显示关卡信息给玩家,然后了解碰撞检测。

碰撞是许多游戏的核心,当精灵碰撞时就会发生。 一旦你能够检测到碰撞,就可以编写代码让精灵做出反应。在泡泡射手中,碰撞发生在发射的泡泡撞到游戏面板中的另一个泡泡时。我们将实现两种反应:如果发射的泡泡没有形成三个或更多泡泡的颜色组,它将粘附在面板上,或者它将导致一个有效的颜色组从面板上掉落。

但在我们计算碰撞之前,我们需要一个对象来与泡泡发生碰撞。本章的第一部分讨论了如何绘制初始面板和设置游戏状态。为此,我们需要遵循一个包含多个步骤的过程,如图 3-1 所示。

游戏循环从绘制面板开始,最后显示得分。

图 3-1。游戏循环从绘制面板开始,最后显示得分。

我们将首先绘制游戏面板,然后为发射的泡泡添加碰撞检测。在下一章中,我们将实现根据颜色匹配弹出泡泡组的机制。

让我们一步步实现这些步骤并将它们转化为代码。

绘制游戏面板

每一关的游戏面板结构相似,每个面板包含四种颜色的泡泡行。交替的行包含奇数或偶数个泡泡,具体取决于该行的行号是奇数还是偶数。我们将把这些状态信息存储在一个Board对象中,并将当前的面板作为变量存储在Game对象中。

你选择的对象结构应根据游戏设计的不同而有所变化,但目标应该与决定如何在 Web 应用程序中组织代码时相同:将执行相似操作的对象分组,并在抽象公共功能的程度上保持平衡。不要定义几个包含非常少代码的类,但也不要创建太少类,并将它们的代码写得很长,这样会难以阅读和理解。游戏开发者通常根据直觉、经验以及硬性规则来做出初步的结构决策。如果你认为最初的选择不再有效,始终准备重构代码。

组成棋盘的行将是一个Bubble对象的数组。当我们实例化Board对象时,会创建这个数组。稍后,我们将把棋盘元素的绘制从ui.js转移到 DOM 中。将大量代码放入Game类中很容易,但这并不是我们想要的;因此,尽可能将职责交给其他类,特别是在将对象渲染到屏幕时。

game.js中,我们需要创建一个变量来存储棋盘和一个新的Board对象实例。当点击“新游戏”按钮时,棋盘会被生成。将以下新代码添加到game.js中:

game.js

var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
  var Game = function(){
    var curBubble;
    **var board;**
    *--snip--*
    var startGame = function(){
      $(".but_start_game").unbind("click");
      BubbleShoot.ui.hideDialog();
      curBubble = getNextBubble();
      **board = new BubbleShoot.Board();**
      **BubbleShoot.ui.drawBoard(board);**
      $("#game").bind("click",clickGameScreen);
    };
    *--snip--*
  };
  return Game;
})(jQuery);

Board是我们需要创建的一个新构造函数。创建一个名为board.js的新文件,并将其添加到Modernizr.loadindex.html中的加载文件列表中。将以下代码添加到新文件中:

board.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Board = (function($){
➊  var NUM_ROWS = 9;
➋  var NUM_COLS = 32;
    var Board = function(){
      var that = this;
➌    var rows = createLayout();
➍    this.getRows = function(){ return rows;};
      return this;
    };
    var createLayout = function(){
      var rows = [];
➎    for(var i=0;i<NUM_ROWS;i++){
        var row = [];
➏      var startCol = i%2 == 0 ? 1 : 0;
        for(var j=startCol;j<NUM_COLS;j+=2){
➐        var bubble = BubbleShoot.Bubble.create(i,j);
          row[j] = bubble;
        };
        rows.push(row);
      };
      return rows;
    };
    return Board;
  })(jQuery);

NUM_ROWS ➊ 和 NUM_COLS ➋ 是常量,决定了气泡棋盘网格的行数和列数。列数可能看起来很大,因为我们肯定不会在一行中放 32 个气泡。设置这么大的列数的原因是,我们将为每个气泡的半宽度创建一个网格项,因为奇数行和偶数行在棋盘上是错开的。这一设计决策使得布局更具视觉吸引力,看起来像气泡堆叠在一起。它还为玩家提供了更多有趣的角度来射击。

第一行和每个随后的奇数行上的所有气泡将具有奇数的y坐标,而偶数行上的气泡将具有偶数的y坐标。行数以整数步进增加,但我们将使用的数组从零开始索引:第一行在索引 0,第二行在索引 1,以此类推。因此,气泡坐标(x,y),从气泡棋盘的左上角开始,标记如下图图 3-2 所示。通过这种方式指定坐标并使用半填充的网格,避免了使用半值和小数点。此外,我们可以将棋盘布局存储在由整数索引的数组中。使用整数而不是小数并不会改变我们计算碰撞时的过程,但它确实使代码更具可读性。

游戏网格中气泡的坐标

图 3-2. 游戏网格中气泡的坐标

在代码中,我们现在将调用createLayout函数 ➌,它返回一个二维的行列数组。在下一行 ➍ 中,我们提供了对这个数组的公共访问。一旦我们拥有了Board对象,就可以检索到任何特定行列位置的气泡。例如,要访问坐标(4,1)处的气泡,我们可以写:

var rows = board.getRows();
var row = rows[1];
var bubble = row[4];

泡泡是按行然后按列号访问的。首先,我们通过board.getRows获取所有行,然后将棋盘中的第一行存储为row。接下来,我们通过列号访问row中的第四个泡泡。由于row数组只有一半被填充,在偶数索引行中的所有奇数位置以及奇数索引行中的所有偶数位置将为 null。

createLayout函数包含一个循环➎。对于我们想要创建的每一行,startCol ➏会根据行是奇数行还是偶数行来决定是否从第 1 列或第 0 列开始。然后,另一个循环会递增到最大列数,创建一个新的Bubble对象➐,并将其添加到行数组中,完成后返回该数组。

为了使此函数工作,我们需要调整Bubble类以接受行和列的输入坐标,并且我们需要修改Bubble.create方法。此外,如果一个Bubble对象通过存储其坐标来知道自己在网格中的位置,当我们需要计算需要爆炸的泡泡群时,这些信息将非常有用。当我们知道一个泡泡的位置时,可以访问该泡泡,因为它存储在Board对象中。然后,给定一个泡泡,我们可以查询它的位置。每个泡泡都有一个type属性,对应于它的颜色,该属性在创建时确定。

当你开始编码自己的游戏想法时,关于数据存储和访问方式的决策至关重要。你的解决方案将取决于你所构建的游戏类型。在泡泡射手中,我们将相对较少的Bubbles存储在一个Board对象中。要获取某个特定泡泡的信息,我们可以通过从rows数组中提取数据来访问Board所存储的数据。

根据我们如何使用这些泡泡数据,这种方法可能不是最优雅的解决方案。例如,假设我们想要找到游戏中所有的红色泡泡。目前,我们必须遍历棋盘上的每个位置,检查泡泡是否为红色,然后存储结果。由于游戏网格较小,现代处理器能够快速执行这个操作。只要我们不在每秒钟内运行太多次颜色检查,当前的代码结构应该能够正常工作。

但现在想象一下屏幕上有成千上万个泡泡。遍历所有泡泡仅仅为了找到红色的泡泡将消耗太多处理能力。因此,我们可能想要将泡泡存储在多个数组中——一个存储所有红色泡泡,一个存储所有绿色泡泡,依此类推——以便快速访问每种颜色的所有泡泡。然而,这样做仍然存在权衡:为了检查棋盘上的某个位置是否被泡泡占据(不论颜色如何),我们必须查看多个数组。

当你只是大概知道处理器能够执行操作的速度时,最好使你的代码清晰简洁。如果你的游戏可以正常玩且运行速度足够快,你就不需要尝试不同的方式来访问数据。另一方面,如果你发现瓶颈,你就必须重构一些部分以提高速度。游戏开发是一个迭代过程;你将会像写新代码一样,反复修改已有的代码行。

你如何设计对象以及如何存储它们的数据会因游戏而异。但请记住这一点:如果Game对象需要使用这些数据,无论如何,你必须允许该对象访问这些数据。无论数据是直接存储在变量中,还是存储在Game中的数组中,或是通过Game可以访问的中介对象(如Bubble Shooter中的Board对象)进行访问,代码都需要访问该对象的状态,如果它需要对该对象做出决策的话。

为了支持泡泡存储其在棋盘上的位置和颜色,修改bubble.js如下:

bubble.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Bubble = (function($){
    var Bubble = function(➊**row,col,type**,sprite){
      var that = this;
      **this.getType = function(){ return type;};**
      this.getSprite = function(){ return sprite;};
      **this.getCol = function(){ return col;};**
      **this.getRow = function(){ return row;};**
    };
    Bubble.create = function(➋**rowNum,colNum,type**){
➊    **if(type === undefined){**
➋      **type = Math.floor(Math.random() * 4);**
      **};**
      **var sprite = $(document.createElement("div"));**
      **sprite.addClass("bubble");**
      **sprite.addClass("bubble_" + type);**
      var bubble = new Bubble(**rowNum,colNum,type,**sprite);
      return bubble;
    };
    return Bubble;
  })(jQuery);

Bubble现在接受网格坐标和泡泡类型,以及精灵对象 ➊,其中类型对应于game.css中指定的颜色。Bubble.create方法接受相同的参数 ➋;如果没有传入类型 ➌,则随机选择四种类型(颜色)之一 ➍。

现在我们有了一个Board对象,许多泡泡,以及它们的类型和位置。但所有这些信息完全存储在内存中,并存储在Board对象的rows属性中。接下来,我们将使用这些信息渲染关卡,以便玩家能够看到游戏棋盘。

渲染关卡

绘制关卡是ui类的完美任务,因为ui表示游戏状态,但不影响该状态。

将计算对象位置的代码与渲染该对象到屏幕上的代码分开,是你在所有游戏设计中应该遵循的原则。这不仅将渲染代码与游戏逻辑分离,从而提高了可读性,还使你更容易更改对象的渲染方式。例如,如果Bubble Shooter的棋盘更大且无法完全显示在屏幕上,但我们希望实现缩放或平移功能,我们可以更改渲染棋盘的代码,通过偏移渲染位置或缩放大小来绘制不同大小的棋盘。当我们从基于 DOM 的精灵切换到绘制到 HTML canvas元素时,第六章中分离渲染与游戏逻辑的强大作用将变得显而易见。

由于创建bubble对象涉及创建一个 DOM 精灵元素,因此渲染过程需要将此元素放入文档中并正确定位。以下是这些简单步骤:

  1. 遍历所有行和列,提取每个bubble对象。

  2. 将泡泡的 HTML 写入 DOM。

  3. 将泡泡放置在正确的位置。

你添加的下一段代码将应用这些步骤。打开 ui.js,在 fireBubble 后添加一个新方法(drawBoard),然后在顶部添加一个新的 ROW_HEIGHT 常量:

ui.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.ui = (function($){
    var ui = {
      BUBBLE_DIMS : 44,
      **ROW_HEIGHT : 40,**
      init : function(){
      },
      fireBubble : function(bubble,coords,duration){
        *--snip--*
      }**,**
      **drawBoard : function(board){**
  ➊      **var rows = board.getRows();**
        **var gameArea = $("#board");**
        **for(var i=0;i<rows.length;i++){**
          **var row = rows[i];**
➋        **for(var j=0;j<row.length;j++){**
            **var bubble = row[j];**
➌          **if(bubble){**
➍            **var sprite = bubble.getSprite();**
➎            **gameArea.append(sprite);**
              **var left = j * ui.BUBBLE_DIMS/2;**
              **var top = i * ui.ROW_HEIGHT;**
➏            **sprite.css({**
                **left : left,**
                **top : top**
              **});**
            **};**
          **};**
        **};**
      **}**
    };
    return ui;
  })(jQuery);

drawBoard 方法获取棋盘的行和列 ➊ 并对它们进行循环 ➋。如果有一个泡泡 ➌(回想一下,由于稀疏网格系统,其他 x 坐标位置是 null),drawBoard 会获取 sprite 对象 ➍,将其附加到棋盘上 ➎,并计算其坐标后设置位置 ➏。

为了确定泡泡的位置,drawBoard 首先计算左侧坐标,即泡泡所在列数乘以它宽度的一半。为了计算顶部坐标,我们会使用一个比 BUBBLE_DIMS 高度稍小的值。奇数行和偶数行是错开的,我们希望泡泡看起来像是紧密排列在一起的。为了创建堆叠效果,垂直间距会稍微小于水平间距。在 ui.js 的顶部,ROW_HEIGHT 已经设置为 40,比高度少了 4 像素。这个值是通过反复试验确定的,而不是通过几何计算得出的:调整这些数字,直到泡泡网格看起来合适为止。

重新加载并点击 新游戏;你应该能看到一个渲染良好的棋盘。你甚至可以向棋盘的其他地方发射泡泡;不幸的是,泡泡应该直接穿过而不碰到任何东西,并像之前一样继续飞出屏幕。

因为我们只有一个泡泡,我们需要刷新才能重新尝试这个过程。在开始处理碰撞检测之前,我们将确保可以连续发射一个泡泡接一个泡泡。

泡泡队列

尽管玩家只有有限数量的泡泡可以发射,但游戏需要提供持续不断的泡泡流。因此,我们需要添加一个函数,创建一个新的泡泡,将其添加到 DOM 中,并在玩家发射第一个泡泡后立即排队准备下一个泡泡。

game.js 中,添加以下变量和函数,并更改 curBubble 的初始化,调用一个新的 getNextBubble 函数:

game.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
    var Game = function(){
    var curBubble;
    var board;
➊  **var numBubbles;**
➋  **var MAX_BUBBLES = 70;**
    this.init = function(){
      $(".but_start_game").bind("click",startGame);
    };
    var startGame = function(){
      $(".but_start_game").unbind("click");
➌    **numBubbles = MAX_BUBBLES;**
      BubbleShoot.ui.hideDialog();
      curBubble = getNextBubble();
      board = new BubbleShoot.Board();
      BubbleShoot.ui.drawBoard(board);
      $("#game").bind("click",clickGameScreen);
    };
    var getNextBubble = function(){
      var bubble = BubbleShoot.Bubble.create();
      bubble.getSprite().addClass("cur_bubble");
      $("#board").append(bubble.getSprite());
➍    **BubbleShoot.ui.drawBubblesRemaining(numBubbles);**
      **numBubbles--;**
      return bubble;
    };
    var clickGameScreen = function(e){
      var angle = BubbleShoot.ui.getBubbleAngle(curBubble .getSprite(),e);
      var duration = 750;
      var distance = 1000;
      var distX = Math.sin(angle) * distance;
      var distY = Math.cos(angle) * distance;
      var bubbleCoords = BubbleShoot.ui.getBubbleCoords(curBubble .getSprite());
      var coords = {
        x : bubbleCoords.left + distX,
        y : bubbleCoords.top - distY
      };
      BubbleShoot.ui.fireBubble(curBubble,coords,duration);
➎    **curBubble = getNextBubble();**
    };
    return Game;
  })(jQuery);

新代码首先创建了一个变量 ➊ 来存储玩家已发射的泡泡数量。因为发射的泡泡数量是一个整数——一种基本数据类型——所以我们将它作为变量存储在 Game 中。例如,如果我们有一个时间限制,需要在某个时间内完成关卡,我们可能会创建一个对象来存储剩余时间和剩余泡泡,而不是继续在 Game 中创建多个变量。就目前来说,这个变量已经能满足我们的需求。

代码还设置了一个常量,表示玩家可以发射的最大泡泡数量 ➋。当关卡开始时,代码将剩余泡泡数设置为 MAX_BUBBLES 的值 ➌,并调用 ui.js 中的新函数以显示屏幕上剩余泡泡的数量 ➍。最后,每次发射泡泡时,代码都会调用 getNextBubble ➎ 准备下一个泡泡。

我们还希望显示玩家在关卡中剩余可发射的气泡数量,因此在ui.js中创建drawBubblesRemaining方法,将这个新函数添加到ui对象中:

ui.js

var BubbleShoot = window.BubbleShoot || {};
BubbleShoot.ui = (function($){
  var ui = {
    BUBBLE_DIMS : 44,
    ROW_HEIGHT : 40,
    *--snip--*
    drawBoard : function(board){
      *--snip--*
    }**,**
    **drawBubblesRemaining : function(numBubbles){**
      **$("#bubbles_remaining").text(numBubbles);**
    **}**
  };
  return ui;
})(jQuery);

此外,我们需要显示剩余气泡的数量,所以在index.html中添加一个新元素:

index.html

<div id="game">
  <div id="board"></div>
  **<div id="bubbles_remaining"></div>**
</div>

bubbles_remaining div 添加一些样式到main.css

main.css

#bubbles_remaining
{
  position: absolute;
  left: 479px;
  top: 520px;
  width: 50px;
  font-size: 26px;
  font-weight: bold;
  color: #000;
  text-align: center;
}

现在刷新游戏。你应该能够把气泡射到远处,每当第一个气泡发射后,立刻得到一个新气泡(直到你使用了 70 个气泡,或者你为MAX_BUBBLES设置的其他值),并能立即发射那个新气泡。

通常,你可以将游戏分解为一个重复的回合循环。这个循环通常由玩家操作启动,然后在操作解决后结束。在Bubble Shooter中,循环在玩家点击屏幕发射按钮时开始,当下一个气泡准备好发射时结束。此时我们有了基本的回合循环,但为了创建游戏,我们需要完善循环的中间部分,计算气泡停止的位置以及是否需要爆炸气泡。

检测碰撞

尽管现在你可以发射气泡,但它们会直接穿过板面,不会影响气泡网格。游戏设计要求它们与板面碰撞,并要么成为板面的一部分,要么导致相同颜色的气泡组爆炸。接下来的任务是计算碰撞发生的地方。

我们可以通过两种方式计算碰撞:

  • 每帧将一个精灵向前移动几个像素,然后尝试检测是否与其他精灵重叠。如果发生重叠,我们就知道已经碰到另一个气泡。

  • 使用几何学来计算精灵在开始移动之前可能与其他气泡发生碰撞的地方。

在快节奏的街机游戏中,只要没有发生物体穿透而未检测到碰撞的可能性,你可能会选择第一种方案。这些穿透可能发生在物体以高速移动时,而碰撞检查发生在物体自上次检查以来已经移动了多个像素之后。例如,在一个射击子弹的游戏中,如果你向一堵一英尺厚的墙开火,只有每英尺检查一次碰撞,才能确保子弹与墙碰撞。如果你每两英尺检查一次碰撞,可能会在子弹应该击中之前检查碰撞,结果发现没有墙壁。然后在再检查两英尺时,子弹已经穿过墙壁,从而导致没有碰撞。

为了绕过快速移动物体的问题,我们可以确保每一步的距离足够小,以避免物体穿透;然而,这需要更多的计算,可能在没有强大计算能力的情况下无法实现。这个问题在浏览器环境中更容易出现:因为我们无法预知最终用户计算机的规格,所以不能假设处理能力。

第二种选择是使用几何方法,如果可行的话,它更为准确。幸运的是,我们的游戏设计具有相对简单的几何特性。不幸的是,在精灵具有更复杂形状的游戏中,这种方法不可行。在这种情况下,您必须逐帧检查像素是否重叠,并进行彻底测试以确保不会出现任何副作用。对于Bubble Shooter(气泡射手),我们将采用几何方法,因为我们具备以下优势:

  • 游戏使用规则网格。

  • 所有物体(气泡)都是相同的。

  • 我们只在二维空间中进行工作。

  • 玩家只移动一个物体。

  • 所有的物体都是简单的几何形状(圆形),因此计算边缘相交的位置非常简单。

这些条件使得碰撞的几何计算相对简单。由于游戏开发通常涉及大量的几何学,因此掌握三角学和向量是至关重要的。下一部分将讨论游戏中的几何形状,然后我们将把这些几何形状转化为代码。

碰撞几何

当您需要计算碰撞时,先在纸上画出几何图形,然后再编写检测代码。这样,您将能够可视化您需要计算的值,如图 3-3 所示。

可视化气泡碰撞背后的几何形状

图 3-3。可视化气泡碰撞背后的几何形状

当发射气泡的中心距离另一个气泡的中心小于 2R(其中 R 是气泡的半径)时,应该发生碰撞,这意味着两个气泡的圆周接触。由于交点总是与碰撞气泡的边缘以及被撞气泡的边缘成 90 度垂直,因此我们只需要检查当移动气泡的中心路径距离另一个气泡的中心小于 2R 时,是否发生碰撞。

为了确定碰撞发生的位置,我们需要检查棋盘上的每个其他气泡,判断发射的气泡的路径是否经过它。如果它与多个气泡重叠,就像在图 3-4 中所示,我们需要确保我们选中的撞击气泡是第一个发生碰撞的气泡,也就是发射气泡行进距离最短的那个。

发射的气泡可能会与多个其他气泡发生碰撞。

图 3-4。发射的气泡可能会与多个其他气泡发生碰撞。

检测碰撞等同于检测从我们发射的气泡的中心线绘制的向量与一个半径为我们气泡两倍的圆相交的时刻。这将被称为气泡的 碰撞框。图 3-5 展示了我们如何重新绘制这个概念,帮助我们以一种更容易计算的方式理解它。

如果发射的气泡的运动路径与一个静止气泡的圆形碰撞框相交,就会发生碰撞。

图 3-5. 如果发射的气泡的运动路径与一个静止气泡的圆形碰撞框相交,就会发生碰撞。

在这个图示中,填充的小圆圈标记了发射气泡的中心。它将碰撞的气泡是内圈,而气泡的碰撞框(标记为箭头指示的 2R点,即气泡半径的两倍)是气泡停止的地方。

将图示转化为数学公式意味着使用向量。我们不需要在展示任何代码之前先进行数学推导,而是直接进入必要的 JavaScript 代码,并附上说明性注释。

简化碰撞检测框

由于我们处理的是圆形,创建一个碰撞检测框比处理例如像平台游戏中的人物那样的角色要简单。在那种情况下,你可能不希望仅仅通过检查像素是否重叠来检测碰撞,因为可能会出现性能问题;相反,你可以简化主角的几何形状,创建一个矩形的碰撞检测框来进行检测。并非所有游戏都适合这种方法。然而,如果你能够将复杂的角色轮廓简化为简单的几何形状,你就能够比通过检查像素重叠来检测碰撞更加精确且消耗更少的计算资源。总是要寻找创造性和高效的解决方案,避免占用资源的暴力计算方法。

这个计算是一个包含特定功能的大块代码,因此我们将其放入一个单独的文件中。创建一个名为 collision-detector.js 的文件,并将其添加到 index.html 中的 Modernizr.load 调用中。输入以下内容:

collision-detector.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.CollisionDetector = (function($){
    var CollisionDetector = {
      findIntersection : function(curBubble,board,angle){
        var rows = board.getRows();
        var collision = null;
        var pos = curBubble.getSprite().position();
        var start = {
          left : pos.left + BubbleShoot.ui.BUBBLE_DIMS/2,
          top : pos.top + BubbleShoot.ui.BUBBLE_DIMS/2
        };
        var dx = Math.sin(angle);
        var dy = -Math.cos(angle);
        for(var i=0;i<rows.length;i++){
          var row = rows[i];
          for(var j=0;j<row.length;j++){
            var bubble = row[j];
            if(bubble){
➊            var coords = bubble.getCoords();
              var distToBubble = {
                x : start.left - coords.left,
                y : start.top - coords.top
              };
              var t = dx * distToBubble.x + dy * distToBubble.y;
              var ex = -t * dx + start.left;
              var ey = -t * dy + start.top;
              var distEC = Math.sqrt((ex - coords.left) * (ex - coords.left) +
                (ey - coords.top) * (ey - coords.top));
              if(distEC<BubbleShoot.ui.BUBBLE_DIMS * .75){
                var dt = Math.sqrt(BubbleShoot.ui.BUBBLE_DIMS * BubbleShoot.
                  ui.BUBBLE_DIMS - distEC * distEC);
                var offset1 = {
                  x : (t - dt) * dx,
                  y : -(t - dt) * dy
                };
                var offset2 = {
                  x : (t + dt) * dx,
                  y : -(t + dt) * dy
                };
                var distToCollision1 = Math.sqrt(offset1.x * offset1.x +
                  offset1.y * offset1.y);
                var distToCollision2 = Math.sqrt(offset2.x * offset2.x +
                  offset2.y * offset2.y);
                if(distToCollision1 < distToCollision2){
                  var distToCollision = distToCollision1;
                  var dest = {
                    x : offset1.x + start.left,
                    y : offset1.y + start.top
                  };
                }else{
                  var distToCollision = distToCollision2;
                  var dest = {
                    x : -offset2.x + start.left,
                    y : offset2.y + start.top
                  };
                }
                if(!collision || collision.distToCollision>distToCollision){
                  collision = {
                    bubble : bubble,
                    distToCollision : distToCollision,
                    coords : dest
                  };
                };
              };
            };
          };
        };
        return collision;
      }
    };
    return CollisionDetector;
  })(jQuery);

稍后我将拆解 collision-detector.js 中的代码。但首先,注意到在 bubble.js 中调用了一个名为 getCoords ➊ 的新方法,它根据气泡在行/列层次结构中的位置返回气泡的中心坐标(x, y)。你需要修改气泡类以添加这个新方法:

bubble.js

var BubbleShoot = window.BubbleShoot || {};
BubbleShoot.Bubble = (function($){
  var Bubble = function(row,col,type,sprite){
    var that = this;
    this.getType = function(){ return type;};
    this.getSprite = function(){ return sprite;};
    this.getCol = function(){ return col;};
    this.getRow = function(){ return row;};
    **this.getCoords = function(){**
      **var coords = {**
        **left :** ➊**that.getCol() *** ➋**BubbleShoot.ui.BUBBLE_DIMS/2 +**
          ➎**BubbleShoot.ui.BUBBLE_DIMS/2,**
        **top :** ➌**that.getRow() *** ➍**BubbleShoot.ui.ROW_HEIGHT +**
          ➎**BubbleShoot.ui.BUBBLE_DIMS/2**
      **};**
      **return coords;**
    **}**
  };
  Bubble.create = function(rowNum,colNum,type){
    --*snip*--
  };
  return Bubble;
})(jQuery);

气泡的游戏坐标非常简单计算:首先找到每个左上角的坐标。x坐标(左侧)是列号➊乘以气泡精灵宽度的一半➋。y坐标(顶部)是行号➌乘以行高➍,它稍微小于气泡的完整高度。要找到气泡的中心,只需将气泡的宽度和高度的一半➎分别加到xy上。

在开发游戏逻辑时,通常更关注一个物体的中心坐标,而在渲染过程中,通常会指定左上角坐标以及宽度和高度。为物体构建一个从一种坐标系统转换为另一种坐标系统的便捷方法,可以避免每次切换时都需要手动编写数学公式。

碰撞检测逻辑

现在让我们逐步解析findIntersection函数在CollisionDetector.js中的实现。如果你现在不想深入数学细节,可以跳过这部分内容——它仅仅是关于检测碰撞的数学原理,并没有涉及新的 HTML5 或游戏开发概念。然而,请知道,在几乎所有你编写的游戏中,你都会将物体之间的交互复杂性分解为可以用相对简单的数学运算来操作的模型。

起始位置和方向向量

添加到collision-detector.js的第一部分是标准库的引入:

var BubbleShoot = window.BubbleShoot || {};
BubbleShoot.CollisionDetector = (function($){
  var CollisionDetector = {

我们创建了一个名为CollisionDetector的对象。现在让我们来看一下该对象中的第一个方法:

findIntersection : function(curBubble,board,angle){

当你调用CollisionDetector时,你将使用BubbleShoot.CollisionDetector.findIntersection。它接受curBubbleBubble类的实例)、board变量(Board类的实例)以及气泡发射的角度作为参数,提供给函数关于初始情况的所有必要信息。

现在,检查findIntersection中的第一个变量:

var rows = board.getRows();
var collision = null;

我们将循环遍历每一行以检查碰撞,因此我们将把棋盘的行数存储到一个局部变量中。假设默认情况下没有碰撞发生,那么如果没有发生交集,函数将返回这个状态。结果是,如果发射的气泡没有击中其他气泡,它将继续向前移动。

collision的初始值是null而不是false,因为如果发生了交集,它将保存被碰撞的气泡及其相关信息,而不是一个表示是否发生碰撞的布尔值。我们需要知道碰撞是否发生(这将是一个“真”或“假”的结果),但更重要的是,我们需要返回有关发生碰撞的物体及碰撞发生位置的信息:

var pos = curBubble.getSprite().position();
var start = {
  left : pos.left + BubbleShoot.ui.BUBBLE_DIMS/2,
  top : pos.top + BubbleShoot.ui.BUBBLE_DIMS/2
};

下一对变量检索气泡的起始位置(在屏幕上的位置),并以一个具有topleft属性的对象形式表示:

var dx = Math.sin(angle);
var dy = -Math.cos(angle);

最后,dxdy 定义了气泡相对于其将移动的总距离,分别向左或向右(dx)或向上(dy)移动的距离。有了这些变量定义后,我们可以遍历游戏板的行和列:

for(var i=0;i<rows.length;i++){
  var row = rows[i];
  for(var j=0;j<row.length;j++){
    var bubble = row[j];
    if(bubble){

我们从游戏板的左上角开始,逐步向下和向右移动。因为我们只会向上发射气泡,所以我们知道气泡永远不会与来自游戏板顶部的其他气泡发生碰撞。我们还知道,如果在气泡路径上有多个碰撞候选气泡,我们希望选取气泡移动距离最小的那个——也就是最先发生的碰撞。记住,因为列是稀疏分布的(每隔一项是空的),我们还需要确保在尝试对气泡进行操作之前,我们实际正在查看一个气泡——因此需要进行if(bubble)检查。

计算碰撞

接下来,我们需要使用一些几何学来检查发射的气泡的碰撞框是否与另一个气泡相撞。我们将确定由(dx,dy)定义的向量(该向量从发射气泡的中心开始)与图 3-4 中绘制的圆相交的位置。我们从圆的方程式开始:

(xc[x])² + (yc[y])² = r²

在这里,xy是圆周上的点,c[x]和c[y]是圆心点,r是圆的半径。我们将需要这些点来找到与起始气泡的距离。

var coords = bubble.getCoords();
var distToBubble = {
  x : start.left - coords.left,
  y : start.top - coords.top
};

这一部分的循环包含了一个气泡,用来检查是否发生碰撞,因此我们获取c[x]和c[y],即气泡的中心坐标(在前面的代码中是coords),并计算这个点与发射气泡坐标之间的距离。我们还不知道是否会发生碰撞。

发射的气泡遵循一组由以下方程式定义的坐标:

p[x] = e[x] + td[x]
p[y] = e[y] + td[y]

其中 p[x] 和 p[y] 是气泡中心点的轨迹上的点。p[x] 和 p[y] 的计算发生在 jQuery 的 animate 方法中,这是沿直线移动点的标准方程式。接下来,我们将计算* t *在与我们检查的气泡中心最近的线上点:

var t = dx * distToBubble.x + dy * distToBubble.y;

这一行告诉我们,在发射气泡的总运动中,哪一个比例它最接近候选气泡的中心。由此,我们可以计算出发生这种情况时的屏幕坐标:

var ex = -t * dx + start.left;
var ey = -t * dy + start.top;

有了这些坐标,我们可以找到e(发射气泡中心线到候选气泡中心的最近点)的距离:

var distEC = Math.sqrt((ex - coords.left) * (ex - coords.left) + (ey -
  coords.top) * (ey - coords.top));

如果距离distEC小于候选气泡半径的两倍,则发生碰撞。如果不是,发射的气泡将不会与这个候选气泡发生碰撞。

试错法与计算法

请注意,虽然BubbleShoot.ui.BUBBLE_DIMS给出了精灵的宽度和高度,但我们正在检查的distEC是与一个实际上稍小的气泡图像进行比较的。将BUBBLE_DIMS值乘以 0.75(通过一些反复试验得出的)得到一个适用于游戏的气泡直径。

我们可以通过测量气泡的宽度来得到一个更精确的distEC值,书中的图像中气泡宽度为 44 像素。将其除以 50 像素的BUBBLE_DIMS,结果是一个 0.88 的倍数。虽然这个较大的值可能更精确,但它要求玩家在尝试通过间隙发射气泡时更精确。因此,0.75 这个值对玩家来说感觉更好,因为它给予了玩家更多的机会来完成那些如果精确计算的话非常困难的投篮。

在游戏开发中,通常你会根据反复试验和计算做出决定。在这种情况下,通过使用一个稍小的值,你给了玩家在游戏板上通过小间隙发射气泡的机会。玩家不会注意到物理法则的松懈执行,他们会更享受游戏。

如果distEC小于气泡精灵宽度的四分之三,我们知道发射的气泡路径在某个点与候选气泡的碰撞框相交:

if(distEC < BubbleShoot.ui.BUBBLE_DIMS * .75){

很可能,第二个交点会出现在线条退出气泡碰撞框的地方(见图 3-5,其中显示了发射气泡的中心线在两个点穿过碰撞框),但我们只关心第一个交点。通过两个计算,我们可以确保得到正确的交点。让我们看看第一个计算:

var dt = Math.sqrt(BubbleShoot.ui.BUBBLE_DIMS * BubbleShoot.ui.BUBBLE_DIMS
  - distEC * distEC);

这里,我们计算了被撞气泡的中心与发射气泡路径上最近点之间的距离。第二个计算如下:

var offset1 = {
  x : (t - dt) * dx,
  y : -(t - dt) * dy
};
var offset2 = {
  x : (t + dt) * dx,
  y : -(t + dt) * dy
};

通过计算穿过静止气泡中心的线上的点,这些点作为从发射气泡路径上t点的偏移量被计算出来。

找到正确的碰撞点

现在我们想选择首先遇到的交点——也就是说,选择距离我们发射curBubble的地方最近的点——所以我们需要计算到每个潜在碰撞点的距离:

var distToCenter1 = Math.sqrt(offset1.x * offset1.x + offset1.y *
  offset1.y);
var distToCenter2 = Math.sqrt(offset2.x * offset2.x + offset2.y *
  offset2.y);

接下来,我们将选择正确的碰撞点,并通过将起始坐标重新添加到系统中来计算curBubble需要停止的位置。

if(distToCollision1 < distToCollision2){
  var distToCollision = distToCollision1;
  var dest = {
    x : offset1.x + start.left,
    y : offset1.y + start.top
  };
}else{
  var distToCollision = distToCollision2;
  var dest = {
    x : -offset2.x + start.left,
    y : offset2.y + start.top
  };
}

大多数时候,如果被发射的气泡的中心与另一个气泡的边缘发生碰撞,它会有两个交点:一次是进入时,另一次是退出时。在少数情况下,它只是擦过并且只会有一个碰撞点发生,我们会得到两个相同的结果,因此选择哪一个并不重要。

此时,函数将遍历显示中的每个气泡并检查碰撞;然而,我们不需要知道 每个 碰撞——只需要知道 最接近的 碰撞,并且该碰撞应该发生在 curBubble 的运动路径中最早的时刻。

为了存储当前最佳匹配的碰撞,我们使用 collision 变量,该变量在循环开始前被设置为 null。然后,每次我们发现一个碰撞时,我们都会检查新碰撞是否比之前的最佳碰撞更接近。如果没有发生过先前的碰撞,那么我们找到的第一个碰撞将是最佳的。collision 对象存储了被碰撞的静止气泡的引用、碰撞的距离以及发生碰撞的坐标:

         if(!collision || collision.distToCollision>distToCollision){
            collision = {
              bubble : bubble,
              distToCollision : distToCollision,
              coords : dest
            };
          };
        };
      }
    }
  };
  return collision;
};

现在,如果发现碰撞,findIntersection 函数将返回一个包含所有我们需要的数据的对象;如果没有碰撞发生,则返回 null。所有这些计算都在气泡开始移动之前完成。

反应碰撞

现在我们需要在 game.js 中的 clickGameScreen 修改版中使用我们获得的碰撞坐标,以便我们可以发射和停止气泡。我们已经编写了检测碰撞的第一步,通过解析气泡碰撞的对象(可能是 没有碰撞!)。现在,Game 需要根据这些信息来决定如何反应。

首先,我们检查是否发生了碰撞。如果发生了碰撞,我们将气泡移动到碰撞发生的位置。如果没有碰撞发生,我们将气泡发射到屏幕外。将现有的 clickGameScreen 函数修改为以下内容:

game.js

  var clickGameScreen = function(e){
    var angle = getBubbleAngle(e);
    var bubble = $("#bubble");
    var duration = 750;
    var distance = 1000;
    **var collision = BubbleShoot.CollisionDetector.findIntersection(curBubble,**
      **board,angle);**
    **if(collision){**
      **var coords = collision.coords;**
  ➊    **duration = Math.round(duration * collision.distToCollision / distance);**
    **}else{**
      var distX = Math.sin(angle) * distance;
      var distY = Math.cos(angle) * distance;
      var bubbleCoords = BubbleShoot.ui.getBubbleCoords(curBubble.getSprite());
      var coords = {
        x : bubbleCoords.left + distX,
        y : bubbleCoords.top - distY
      };
    **};**
    BubbleShoot.ui.fireBubble(curBubble,coords,duration);
    curBubble = getNextBubble();
  };

如果气泡移动的距离由于碰撞发生了变化,那么到达目标所需的时间也应该发生变化,这样所有气泡才能以相同的速度发射。我们将使用碰撞数据重新计算该持续时间 ➊。

重新加载游戏并发射一个气泡。气泡应该在碰到主组时停止。但它仍然看起来不太对。气泡停止了,但并没有整合到棋盘上。它只是停在了碰撞的位置。此外,如果你发射更多气泡,它们会堆叠在一起;新气泡不会与之前发射的气泡发生碰撞。问题在于棋盘状态没有与显示状态同步变化,因此我们将通过两个步骤来纠正这一问题:

  1. 将发射的气泡添加到棋盘状态中的正确行和列。

  2. 当发射的气泡停止时,将其锁定到一个整齐的网格位置。

第二步将使用第一步中的信息。

将气泡对象添加到棋盘

bubble 对象 curBubble 在 DOM 中,应该最终接近正确的位置,所以我们可以在知道它应该放置的位置时将其添加到棋盘的行/列数组中。

为了计算行号,我们将y坐标除以行的高度并向下取整。计算列号类似,只不过我们需要将列号调整为偶数行的奇数列号(包括零)或奇数行的偶数列号。最后,我们可以将气泡添加到Board对象的rows属性中,因为Board是我们存储所有气泡位置数据的地方。

发射气泡的函数非常简单,所以我们会把它放到board.js中。在 board 类的定义中,getRows方法之后,添加如下内容:

board.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Board = (function($){
    var NUM_ROWS = 9;
    var NUM_COLS = 32;
    var Board = function(){
      var that = this;
      var rows = createLayout();
      this.getRows = function(){ return rows;};
      **this.addBubble = function(bubble,coords){**
        **var rowNum = Math.floor(coords.y / BubbleShoot.ui.ROW_HEIGHT);**
        **var colNum = coords.x / BubbleShoot.ui.BUBBLE_DIMS * 2;**
        **if(rowNum % 2 == 1)**
          **colNum -= 1;**
        **colNum = Math.round(colNum/2) * 2;**
        **if(rowNum % 2 == 0)**
          **colNum -= 1;**
        **if(!rows[rowNum])**
          **rows[rowNum] = [];**
➊        **rows[rowNum][colNum] = bubble;**
➋        **bubble.setRow(rowNum);**
➌        **bubble.setCol(colNum);**
      **};**
      return this;
    };
    var createLayout = function(){
      *--snip--*
    };
    return Board;
  })(jQuery);

请注意,在将气泡添加到rows[][]的正确行列位置时 ➊,我们还将计算得到的行 ➋ 和列 ➌ 数字传递给bubble对象,以便它了解自己相对于其他气泡的位置。我们还没有这些方法调用,所以现在让我们在bubble.js中为Bubble类定义它们:

bubble.js

var Bubble = function(row,col,type,sprite){
  var that = this;
  this.getType = function(){ return type;};
  this.getSprite = function(){ return sprite;};
  this.getCol = function(){ return col;};
  **this.setCol = function(colIn){ col = colIn;};**
  this.getRow = function(){ return row;};
  **this.setRow = function(rowIn){ row = rowIn;};**
  this.getCoords = function(){
    *--snip--*
  }
};

接下来,修改game.js,在clickGameScreen中调用这个新方法:

game.js

var clickGameScreen = function(e){
  var angle = BubbleShoot.ui.getBubbleAngle(curBubble.getSprite(),e);
  var duration = 750;
  var distance = 1000;
  var collision = BubbleShoot.CollisionDetector.findIntersection(curBubble,
    board,angle);
  if(collision){
    var coords = collision.coords;
    duration = Math.round(duration * collision.distToCollision / distance);
    **board.addBubble(curBubble,coords);**
  }else{
    var distX = Math.sin(angle) * distance;
    var distY = Math.cos(angle) * distance;
    var bubbleCoords = BubbleShoot.ui.getBubbleCoords(curBubble.getSprite());
    var coords = {
      x : bubbleCoords.left + distX,
      y : bubbleCoords.top - distY
    };
  };
  BubbleShoot.ui.fireBubble(curBubble,coords,duration);
  curBubble = getNextBubble();
};

重新加载游戏并发射一些气泡。它们应该开始堆积,尽管有些气泡可能仍然会重叠,因为它们没有完全正确地放入网格中。这是进步,但我们希望气泡在碰撞时能够排列整齐——这就是我们接下来要做的。

将气泡对象锁定到网格中

当发射的气泡与棋盘上的其他气泡发生碰撞时,我们希望将它们锁定在原地,而不是让它们停在任何它们碰到的地方。当前的运动效果很好,但我们需要再添加一步,当气泡到达目的地时,将它锁定在位置上。

board.addBubble执行后,bubble对象知道它位于哪个行列位置;因此,调用它的getCoords方法(根据行列计算)将获取它应该位于的位置坐标,而不是它实际停止的位置坐标。为了将它调整到正确的位置,我们将添加一个complete函数,它可以作为 jQuery animate调用的一部分设置,并使用气泡已经拥有的信息。这样,我们就可以发射气泡并不再担心它,而不是创建一个过程来整理气泡的落地情况。jQuery 的complete回调函数是一个非常有用的地方,用于放置动画结束时需要运行的代码。例如,在一个带有爆炸效果的游戏中,动画的帧可以运行,动画结束时,构成爆炸的 DOM 元素可以从屏幕上移除。

当动画结束时,会调用complete属性。在ui.js中,修改fireBubble如下:

ui.js

  fireBubble : function(bubble,coords,duration){
    bubble.getSprite().animate({
        left : coords.x - ui.BUBBLE_DIMS/2,
        top : coords.y - ui.BUBBLE_DIMS/2
      },
      {
        duration : duration,
        easing : "linear"**,**
        **complete : function(){**
➊        **if(bubble.getRow() !== null){**
            **bubble.getSprite().css({**
              **left : bubble.getCoords().left - ui.BUBBLE_DIMS/2,**
              **top : bubble.getCoords().top - ui.BUBBLE_DIMS/2**
            **});**
          **};**
      **}**
    });
  },

重新加载后,您发射的气泡应该会落入网格系统。请注意,我们使用getRow来检查是否发生了碰撞 ➊,因为getRow应该为错过所有其他气泡并离开屏幕的气泡返回null

总结

现在,已发射的气泡与板上的其他气泡发生碰撞,泡泡射手 开始更像一个游戏了。我们已经使用 jQuery 移动了精灵,令游戏响应玩家的输入,并设置了一些基本的游戏逻辑。然而,目前还没有办法爆破气泡,如果没有这个功能,游戏就不完整了。爆破逻辑和显示动画将是下一章的内容。

进一步练习

  1. 游戏板的每一行都有偏移,形成一个错落的图案。修改 createLayout 中的代码,使气泡形成规则的网格。这会如何改变游戏?

  2. 现在你可以让 createLayout 构建不同的网格图案,编写代码生成一个全新的布局。例如,你可以仅绘制每隔一列的气泡,或者构建一个更具创意的布局。

  3. 泡泡射手 具有一个简单的对象结构,包含 GameBoard 和一组 Bubbles。如果你正在构建像 愤怒的小鸟宝石迷阵糖果传奇 这样的游戏,你会需要哪些对象?

第四章. 将游戏状态变化翻译为显示效果

动画是一种强大的视觉提示,用于向玩家展示他们的动作如何影响游戏。每当玩家导致游戏状态发生变化时,你需要展示结果。在本章中,你将添加代码来检测并移除气泡组合,了解如何为 CSS 精灵添加动画,并在 jQuery 中实现一个漂亮的爆炸效果。

在这一点上,玩家可以向游戏板发射气泡,这些气泡将成为气泡网格的一部分。现在,我们需要在玩家发射正确颜色的气泡时,弹出匹配气泡的组合。当curBubble被发射到另一个气泡上,并且形成三个或更多匹配气泡的组合时,该组合中的所有气泡应该展示爆炸动画,然后从显示屏和Board对象中移除。

我们还需要检测并处理气泡爆炸所引发的任何连锁反应。例如,如果当我们消除另一组气泡时,某些气泡与主组合断开连接,我们应该以不同的方式销毁这些断开的气泡。

计算组合

Board对象包含网格中每个气泡的行列信息,并且将确定发射的气泡落地时是否形成三个或更多的组合。我们将在board.js中添加一个函数,该函数返回给定(行,列)位置周围的所有气泡。然后,我们将按颜色对这些气泡进行分组,确定哪些气泡需要被消除。

获取气泡

首先,我们需要从游戏板的rows变量中检索围绕指定坐标的气泡集合。在addBubble方法后,向board.js中添加以下方法:

board.js

  var Board = function(){
    var that = this;
    var rows = createLayout();
    this.getRows = function(){ return rows;};
    this.addBubble = function(bubble,coords){
      --*snip*--
    };
➊  **this.getBubbleAt = function(rowNum,colNum){**
      **if(!this.getRows()[rowNum])**
        **return null;**
      **return this.getRows()[rowNum][colNum];**
    **};**
➋  **this.getBubblesAround = function(curRow,curCol){**
      **var bubbles = [];**
      **for(var rowNum = curRow - 1;rowNum <= curRow+1; rowNum++){**
        **for(var colNum =** ➌**curCol-2; colNum <=** ➍**curCol+2; colNum++){**
          **var bubbleAt = that.getBubbleAt(rowNum,colNum);**
          **if(bubbleAt && !(colNum == curCol && rowNum == curRow))**
➎          **bubbles.push(bubbleAt);**
          **};**
        **};**
      **return bubbles;**
    **};**
    return this;
  }

getBubbleAt方法➊接受行列坐标作为输入,并返回该位置的气泡。如果该位置没有气泡,则返回nullgetBubblesAround方法➋遍历三行相关的行——当前行、上一行和下一行——然后检查周围的列,对于每个位置调用getBubbleAt。请注意,由于行数组是半填充的,getBubbleAt会返回null,对于每个间隔列项都如此。因此,我们需要查看当前气泡左边的两个位置➌(curCol-2)和右边的两个位置➍(curCol+2)。无论我们是从奇数行还是偶数行开始,这个方法应该都能工作。我们还需要检查在我们检查的坐标位置上是否有气泡,并且确保不会把我们正在检查的气泡本身添加进去➎。

所有围绕发射气泡的气泡都会被推入bubbles数组,并由getBubblesAround返回。每个气泡存储自己的坐标,因此我们不需要对数组进行排序或单独存储位置信息。

创建匹配颜色组合

接下来,我们将编写一个更为实质性的函数,名为getGroup,用来返回与第一个气泡颜色相同并与其相连的气泡群。这个递归函数将接受两个参数:一个气泡对象,用于设置起始坐标和颜色(类型)定义;一个对象,用于存储属于该群的气泡。该对象将通过两个数组作为属性来存储找到的气泡:首先是一个线性数组,另外是一个按行和列索引的数组。第二个数组使我们能够轻松检查是否已将气泡添加到匹配集合中,以避免重复添加。两个数组作为对象的属性,以便在调用方法时可以返回这两个数组。下图 图 4-1 展示了该过程的概览。

我们将添加到Board类中的函数如下所示:

board.js

var Board = function(){
  var that = this;
  var rows = createLayout();
  this.getRows = function(){ return rows;};
  this.addBubble = function(bubble,coords){
    --*snip*--
  };
  this.getBubbleAt = function(rowNum,colNum){
    --*snip*--
  };
  this.getBubblesAround = function(curRow,curCol){
    --*snip*--
  };
  **this.getGroup = function(bubble,found){**
    **var curRow = bubble.getRow();**
    **if(!found[curRow])**
      **found[curRow] = {};**
    **if(!found.list)**
      **found.list = [];**
    **if(found[curRow][bubble.getCol()]){**
      **return found;**
    **}**
    **found[curRow][bubble.getCol()] = bubble;**
    **found.list.push(bubble);**
    **var curCol = bubble.getCol();**
    **var surrounding = that.getBubblesAround(curRow,curCol);**
    **for(var i=0;i<surrounding.length;i++){**
      **var bubbleAt = surrounding[i];**
      **if(bubbleAt.getType() == bubble.getType()){**
        **found = that.getGroup(bubbleAt,found);**
      **};**
    **};**
    **return found;**
  **};**
  return this;
};

让我们分解这个新函数并逐步讲解逻辑。在传入bubble对象和found对象后,getGroup首先检查这个气泡是否已经被找到。

   var curRow = bubble.getRow();
➊ if(!found[curRow])
     found[curRow] = {};
➋ if(!found.list)
     found.list = [];
➌ if(found[curRow][bubble.getCol()]){
     return found;
   }
➍ found[curRow][bubble.getCol()] = bubble;
➎ **found.list.push(bubble);**

如果气泡已经被找到,getGroup应该返回当前未改变的数据并停止。如果found对象中没有当前行的条目,我们需要创建一个空数组 ➊。然后,如果list属性不存在,它需要在函数的首次调用时创建 ➋。如果这个气泡之前已被检测到,我们返回已找到的对象,而不再重复添加该气泡 ➌。否则,我们标记已查看此位置 ➍,并将气泡存储在found列表中 ➎。

接下来,我们获取周围的气泡 ➏。

   var curCol = bubble.getCol();
➏ var surrounding = that.getBubblesAround(curRow,curCol);

最多应该有六个气泡,然后我们需要检查每个气泡的颜色是否匹配:

     for(var i=0;i<surrounding.length;i++){
       var bubbleAt = surrounding[i];
➐     if(bubbleAt.getType() == bubble.getType()){
         found = that.getGroup(bubbleAt,found);
       };
     };
➑ return found;

如果一个气泡与发射的气泡颜色匹配 ➐,函数会递归调用自身;getGroup将检查过的气泡添加到平面数组中,并标记其坐标已被检查。函数再次调用自身,传入新找到的气泡和当前的数据状态(包括found列表)。无论结果如何,我们都会返回found的最终值 ➑。

抓取与第一个气泡颜色相同并相连的气泡群

图 4-1:抓取与第一个气泡颜色相同并相连的气泡群

现在,我们需要在气泡发射时调用这个方法。在 game.js 中,添加到clickGameScreen例程中:

game.js

  var clickGameScreen = function(e){
    var angle = BubbleShoot.ui.getBubbleAngle(curBubble.getSprite(),e);
    var duration = 750;
    var distance = 1000;
    var collision = BubbleShoot.CollisionDetector.findIntersection(curBubble,
      board,angle);
    if(collision){
      var coords = {
        x : bubbleCoords.left + distX,
        y : bubbleCoords.top - distY
      };
      duration = Math.round(duration * collision.distToCollision / distance);
      board.addBubble(curBubble,coords);
➊    **var group = board.getGroup(curBubble,{});**
➋    **if(group.list.length >= 3){**
➌      **popBubbles(group.list,duration);**
      **}**
    }else{
      --*snip*--
    };
    BubbleShoot.ui.fireBubble(curBubble,coords,duration);
    curBubble = getNextBubble();
  };

当我们使用board.getGroup获取一组气泡时 ➊,我们可能得到一个包含少于三个气泡的群组。因为我们只考虑包含三个或更多气泡的群组,所以我们会跳过任何较小的群组 ➋。现在,我们只需要编写弹出气泡的例程 ➌!

弹出气泡

我们需要游戏判断一组气泡是否有三颗或更多气泡,如果是,则移除这些气泡。在本节中,你将实现移除气泡组合的 JavaScript 函数,并用 CSS 添加一个有趣的破裂动画。

使用 JavaScript 移除气泡组合

我们将首先计算当一组气泡被破裂后,棋盘应该是什么样子。当计算完成后,我们可以更新显示并将破裂的气泡从视图中移除。只要游戏状态计算正确,你就可以在此之后添加动画。更新游戏状态并编写单独的代码来显示新状态是游戏开发中一种非常有用的方法。

clickGameScreen 后添加一个名为 popBubbles 的新函数:

game.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
    var Game = function(){
      --*snip*--
      var clickGameScreen = function(e){
        --*snip*--
      };
      **var popBubbles = function(bubbles,delay){**
➊      **$.each(bubbles,function(){**
          **var bubble = this;**
➋        **board.popBubbleAt(this.getRow(),this.getCol());**
          **setTimeout(function(){**
            **bubble.getSprite().remove();**
          **},delay + 200);**
        **});**
      **};**
    };
    return Game;
  })(jQuery);

popBubbles 函数遍历我们传入的数组中的每个 bubble 对象 ➊,并通过调用 popBubbleAt 告诉棋盘移除该气泡 ➋(我们接下来将编写该函数)。然后,它等待 delay + 200 毫秒后再从 DOM 中移除气泡,以便让发射气泡的动画有时间运行。结果,用户可以在屏幕更新之前看到发生了什么。delay 的初始值是由发射气泡的持续时间传入的——即气泡从起点到达的时间——因此气泡总是在分组发生后的 200 毫秒后消失。

最后一段代码位于 board.js,我们需要定义 popBubbleAt。在 getGroup 方法结束后添加以下方法:

board.js

var Board = function(){
  --*snip*--
  this.getGroup = function(bubble,found){
    --*snip*--
  };
  **this.popBubbleAt = function(rowNum,colNum){**
    **var row = rows[rowNum];**
    **delete row[colNum];**
  **};**
  return this;
};

popBubbleAt 方法仅仅是从行/列数组中移除你传给它的项。

重新加载游戏并发射一个气泡。当你形成三颗或更多的气泡时,它们应该从视图中消失。最终,Bubble Shooter 开始看起来更像一个游戏了!

使用 CSS 的气泡破裂动画

用 CSS 移动 屏幕上的精灵是动画的一种形式,但现在是时候用不同的方式为精灵添加动画,并改变它们的 外观 了。这将为玩家呈现一个视觉上令人满意的气泡破裂动画,并使用我们在书的开头创建的其他精灵帧。

为了给精灵图形添加动画,最好的方法是改变其背景图像的位置。回想一下 bubble_sprite_sheet.png(为了方便,图 Figure 4-2 再次展示)不仅包含了四种气泡类型,还为每种颜色提供了四种不同的状态。

气泡精灵的四种状态,见于 bubble_sprite_sheet.png

图 4-2. 气泡精灵的四种状态,见于 bubble_sprite_sheet.png

我们可以通过连续展示四帧来显示一个气泡破裂的动画,方法是每次将背景图像向左移动 50 像素。

游戏仅在气泡组内进行爆炸,但如果一组气泡同时消失,爆炸效果将不会那么有趣。为了使效果更加有趣,我们将逐个爆炸气泡,而不是同时爆炸。这样做需要对我们刚刚添加到game.js中的popBubbles方法做一些小改动:

game.js

  var popBubbles = function(bubbles,delay){
    $.each(bubbles,function(){
      var bubble = this;
      **setTimeout(function(){**
➊      **bubble.animatePop();**
      **},delay);**
      board.popBubbleAt(bubble.getRow(),bubble.getCol());
      setTimeout(function(){
        bubble.getSprite().remove();
      },delay + 200);
➋    **delay += 60;**
    });
  };

在这里,我们称animatePop ➊为一个新方法,我们将把它添加到Bubble中,用于更改气泡背景图像的位置。第一个气泡的爆炸动画应该在被发射的气泡与其碰撞后立即开始。但随后的爆炸应通过增加delay ➋来延迟 60 毫秒。将animatePop添加到bubble.js中。

bubble.js

  var Bubble = function(row,col,type,sprite){
    --*snip*--
    this.getCoords = function(){
    --*snip*--
    };
    **this.animatePop = function(){**
➊    **var top = type * that.getSprite().height();**
➋    **this.getSprite().css(Modernizr.prefixed("transform"),"rotate(" + (Math.**
        **random() * 360) + "deg)");**
➌    **setTimeout(function(){**
        **that.getSprite().css("background-position","-50px -" + top + "px");**
      **},125);**
      **setTimeout(function(){**
        **that.getSprite().css("background-position","-100px -" + top + "px");**
      **},150);**
      **setTimeout(function(){**
        **that.getSprite().css("background-position","-150px -" + top + "px");**
      **},175);**
➍    **setTimeout(function(){**
        **that.getSprite().remove();**
      **},200);**
    **};**
  };

根据气泡的类型,animatePop计算 ➊表示气泡background-position属性上方部分的值。type值告诉我们气泡应该是什么颜色;我们将用它来选择合适的爆炸动画图像行。接下来,使用基本的 CSS 变换,我们通过将气泡精灵以随机角度旋转,给动画增加一些视觉变化 ➋,以防止所有的爆炸动画看起来一模一样。你将在第五章中看到更多关于 CSS 变换的示例。为了错开每个爆炸动画的开始时间,该函数进行了三次延迟调用 ➌,将background-position向左移动 50 像素。

注意

通过这种方式硬编码动画并不具有很好的可扩展性,但《泡泡射手》只有一个精灵,且仅显示三帧。因此,我们可以避免编写一个通用函数,这也是我们选择使用一系列setTimeout调用的原因。当我们使用canvas渲染实现相同的动画时,你将看到如何编写一个更具可重用性的动画示例。

最后,animatePop在动画完成时会移除精灵的 DOM 元素 ➍。从 DOM 中移除节点有助于内存管理,在一个有更多屏幕对象的游戏中,这一点尤其重要。大约每秒 20 帧的动画帧率相当低。一个专业的游戏应该具有三倍的帧率。但无论如何,通过移动背景图像来创建动画的原理是相同的。

当你重新加载页面并发射一个气泡来形成匹配组时,你应该会看到一个令人愉快的爆炸动画。然而,在爆炸了大量气泡后,你可能会看到我们需要修复的副作用:被爆炸的组可能是唯一将一组不同颜色气泡固定在主板上的元素。目前,这些气泡悬浮在空中,看起来有点奇怪。由于游戏设计要求这些气泡也被移除,我们接下来就会处理这个问题。

孤立的气泡组

与其他气泡断开的气泡组被称为孤立气泡。例如,在图 4-3 中,弹出框中的气泡组会留下四个孤立气泡悬挂在空中。孤立气泡集合也需要被触发气泡移除。但不同于与其他组一起爆裂的方式,我们会添加另一种动画效果。孤立气泡将从屏幕上掉落,看起来就像被切断支撑一样悬挂着。不仅玩家会意识到发生了不同的事情,而且我们也能尝试不同的动画类型。目前,检测孤立组不是代码的一部分;因此,在我们能进行动画之前,我们需要找到它们。

弹出红色气泡会创建四个孤立气泡。

图 4-3. 弹出红色气泡会创建四个孤立气泡。

确定孤立气泡

我们将检查每个气泡,判断它是否属于与任何位于顶行的气泡相连的组。因为顶行被认为是永久连接的,任何无法追溯到顶行的气泡将被标识为孤立组的一部分。

跟踪这一路线可能看起来是一个我们尚未遇到的问题;然而,我们实际上可以利用已经编写的getGroup方法,并相当简单地找到孤立集合。图 4-4 展示了检查一个组是否属于孤立集合的过程。

确定孤立气泡集合的逻辑流程

图 4-4. 确定孤立气泡集合的逻辑流程

使用这个逻辑,我们可以在步骤 2 中重用getGroup函数。但为了做到这一点,我们需要修改气泡必须为相同颜色才能形成组的标准。

让我们修改getGroup,使其能够选择不匹配颜色的气泡组:

board.js

  var Board = function(){
    --*snip*--
➊  this.getGroup = function(bubble,found**,differentColor**){
      var curRow = bubble.getRow();
      if(!found[curRow])
        found[curRow] = {};
      if(!found.list)
        found.list = [];
      if(found[curRow][bubble.getCol()]){
        return found;
      }
      found[curRow][bubble.getCol()] = bubble;
      found.list.push(bubble);
      var curCol = bubble.getCol();
      var surrounding = that.getBubblesAround(curRow,curCol);
      for(var i=0;i<surrounding.length;i++){
        var bubbleAt = surrounding[i];
➋      if(bubbleAt.getType() == bubble.getType() **|| differentColor**){
          found = that.getGroup(bubbleAt,found**,differentColor**);
        };
      };
      return found;
    };
  }

现在,函数定义增加了一个额外的参数➊。对于递归调用getGroup的地方,如果值设置为true,它应忽略类型检查➋,并将输入参数传递通过递归链。通过这些简单的更改,调用getGroup(bubble,{},true)应该返回所有与传入气泡相连的气泡,无论颜色如何。调用getGroup(bubble,{},false)或仅仅getGroup(bubble,{})应与之前的操作方式相同。

findOrphans函数将作为Board类中的一个方法,检查顶行中的每个气泡,找到每个气泡连接到的气泡组。(最初,棋盘上的每个气泡都将在一个大组中,除了将要发射的气泡。)一个包含(row, column)值的数组将用false值填充,每次找到气泡时,该位置的(row, column)条目会被设置为true。在这个过程结束时,包含气泡但返回数组中该位置值为false的坐标将被视为孤立气泡并从游戏中移除。

popBubbleAt之后,将以下代码添加到board.js

board.js

var Board = function(){
  --*snip*--
  this.popBubbleAt = function(rowNum,colNum){
    --*snip*--
  };
  **this.findOrphans = function(){**
    **var connected = [];**
    **var groups = [];**
    **var rows = that.getRows();**
    **for(var i=0;i<rows.length;i++){**
      **connected[i] = [];**
    **};**
    **for(var i=0;i<rows[0].length;i++){**
      **var bubble = that.getBubbleAt(0,i);**
      **if(bubble && !connected[0][i]){**
        **var group = that.getGroup(bubble,{},true);**
        **$.each(group.list,function(){**
          **connected[this.getRow()][this.getCol()] = true;**
        **});**
      **};**
    **};**
    **var orphaned = [];**
    **for(var i=0;i<rows.length;i++){**
      **for(var j=0;j<rows[i].length;j++){**
        **var bubble = that.getBubbleAt(i,j);**
        **if(bubble && !connected[i][j]){**
          **orphaned.push(bubble);**
        **};**
      **};**
    **};**
    **return orphaned;**
  **};**
  return this;
};

让我们更仔细地分析findOrphans函数。首先,我们设置需要的数组,以便找到孤立的气泡组。

➊ var connected = [];
➋ var groups = [];
   var rows = that.getRows();
   for(var i=0;i<rows.length;i++){
     connected[i] = [];
   };

connected数组 ➊是一个二维的行列数组;它标记了连接气泡的位置。groups数组 ➋将包含所有找到的组,如果整个棋盘是连接的,则会是一个组。接下来,我们检查顶行中的每个气泡。

for(var i=0;i<rows[0].length;i++){
  var bubble = that.getBubbleAt(0,i);

在这里,因为我们只关心与顶行相连的气泡,我们只遍历顶行并获取气泡进行检查。当我们找到一个气泡时,就可以开始创建分组。

if(bubble && !connected[0][i]){
  var group = that.getGroup(bubble,{},true);

如果气泡存在且此位置尚未标记为连接的,我们就构建一个组。调用getGroup时,传递true作为第三个参数(differentColor),因为我们不希望通过颜色来限制连接的气泡。

      $.each(group.list,function(){
        connected[this.getRow()][this.getCol()] = true;
      });
  };
};

因为被检查的气泡通过第一行连接,整个组都是连接的;因此,我们将connected数组中的每个条目标记为true

调用findOrphans之后,我们应该得到一个包含连接的行列条目的数组。孤立气泡的列表是我们最终想要的输出,因此我们需要创建另一个空数组来存储该列表。一个一维数组足够了,因为气泡存储了它们自己的坐标:

  var orphaned = [];
  for(var i=0;i<rows.length;i++){
    for(var j=0;j<rows[i].length;j++){
      var bubble = that.getBubbleAt(i,j);
      if(bubble && !connected[i][j]){
        orphaned.push(bubble);
      };
    };
  };
  return orphaned;
};

使用这个新数组,我们检查棋盘上的所有行列,查看每个位置是否有气泡。如果有气泡,但在连接网格中没有条目,则该气泡是孤立的。然后,我们通过调用orphaned.push(bubble)将其添加到孤立气泡列表中。最后,findOrphans返回孤立气泡的数组,如果没有孤立气泡,该数组应为空。

丢弃孤立气泡

现在我们可以找到将被孤立的气泡组,我们需要调用函数并移除所有已识别的孤立气泡。我们不希望气泡爆炸,而是希望孤立的气泡下落,使用一种在爆炸动画完成后进行的动画效果。内部的游戏状态仍然会即时更新,因为我们会在玩家发射气泡后立即计算结果。我们添加延迟不仅是为了提供更具戏剧性的效果,也让玩家能够跟随屏幕上的动作结果。如果我们一知道气泡会被孤立就立即进行下落动画,效果可能会丧失。此外,玩家可能会困惑为何不同颜色的气泡突然消失了。

在这种情况下,将游戏状态与显示状态分离的好处显而易见。我们即时更新游戏状态,玩家几乎可以立刻发射下一个气泡,无需等待动画完成,游戏感觉非常响应迅速。但是在显示状态中,我们大张旗鼓地处理这一游戏状态变化——为了效果并传达玩家的操作如何导致最终结果。动画的方式更像是游戏设计的决策,而非编码决策,但我们编写的游戏代码使得这种设计具有灵活性。

game.js 中,在调用 popBubbles 后添加以下内容:

game.js

  var Game = function(){
    --*snip*--
    var clickGameScreen = function(e){
      --*snip*--
      if(collision){
        --*snip*--
➊      if(group.list.length >= 3){
          popBubbles(group.list,duration);
➋        **var orphans = board.findOrphans();**
➌        **var delay = duration + 200 + 30 * group.list.length;**
➍        **dropBubbles(orphans,delay);**
        };
      }else{
        --*snip*--
      };
      BubbleShoot.ui.fireBubble(curBubble,coords,duration);
      curBubble = getNextBubble();
    };
  };

我们只需要在气泡被爆炸后检查新的孤立气泡➊,因为孤立的气泡组是由气泡爆炸后形成的。只有在形成三个或更多气泡的匹配组时,才会爆炸气泡,因此如果 group.list 大于或等于三,我们需要查找孤立气泡。我们在获取孤立气泡时➋,计算一个延迟时间➌,以确保气泡在所有爆炸完成后掉落。为了执行动画,我们需要编写 dropBubbles ➍。

dropBubbles 方法将会把气泡从屏幕上掉落。在 game.js 中的 popBubbles 函数关闭后,添加以下代码:

game.js

  var Game = function(){
    --*snip*--
    var popBubbles = function(bubbles,delay){
      --*snip*--
    };
    **var dropBubbles = function(**➊**bubbles,delay){**
      **$.each(bubbles,function(){**
        **var bubble = this;**
➋      **board.popBubbleAt(bubble.getRow(),bubble.getCol());**
        **setTimeout(function(){**
➌        **bubble.getSprite().animate({**
            **top : 1000**
          **},1000);**
        **},delay);**
      **});**
    **};**
  };

dropBubbles 函数接收需要掉落的气泡数组➊(我们将传递由 findOrphans 返回的气泡数组)和延迟时间。它会从屏幕上移除气泡➋,然后为气泡掉落动画➌。

刷新游戏并爆破几个气泡组。当你形成一个孤立的气泡组时,这些气泡应该掉落到屏幕上,而不是爆炸。

使用 jQuery 插件爆炸气泡

尽管气泡掉落是一个动画,但它的戏剧性并不足够。让我们把它做得更生动,制造更具爆炸感的效果!我们将编写一个 jQuery 插件来控制这个动画,并将其从游戏系统中抽象出来。

为了让孤立气泡的动画更加引人注目,我们将在气泡下落到屏幕之前先让它们向外爆炸。我们通过给每个气泡赋予初始动量,然后用模拟重力调整其速度来实现这一效果。

虽然将所有代码直接写进dropBubbles中也是可能的,但这样会开始让Game类充满显示逻辑。然而,这个动画非常适合作为 jQuery 插件,优点是我们可以在未来的项目中重用这段代码。

注意

在这个示例中,我只介绍了编写 jQuery 插件的最基本原则。你可以在 learn.jquery.com/plugins/basic-plugin-creation/ 深入学习插件的相关内容。

_js文件夹中创建一个名为jquery.kaboom.js的新文件,并将其添加到Modernizr.load调用中。文件命名约定使得其他人一眼就能看出该文件是一个 jQuery 插件,他们甚至不需要查看代码。

首先,我们通过使用 jQuery 的插件格式来注册该方法——我们将其命名为kaboom

jquery.kaboom.js

(function(jQuery){
  jQuery.fn.kaboom = function(settings)
  {
  };
})(jQuery);

我们将很快补充这段代码;目前它并没有做任何事情。这个函数定义是使用 jQuery 注册新插件的标准方式。它的结构允许像$(...).kaboom()这样的调用,包括传递一个可选的设置参数。

kaboom的调用将在dropBubbles中,因此我们将把这个调用添加到dropBubbles中,并移除animate的调用:

game.js

var Game = function(){
  --*snip*--
  var popBubbles = function(bubbles,delay){
    --*snip*--
  };
  var dropBubbles = function(bubbles,delay){
    $.each(bubbles,function(){
      var bubble = this;
      board.popBubbleAt(bubble.getRow(),bubble.getCol());
      setTimeout(function(){
        **bubble.getSprite().kaboom();**
      },delay);
    });
    return;
  };
};

kaboom方法将为每个对象调用一次。此方法也只会作用于 jQuery 对象;作为一个 jQuery 插件,它不会了解游戏对象,只会与 DOM 元素一起工作,这使得插件在未来的游戏中可以重用。

jquery.fn.kaboom中,我们将使用一个数组来存储当前正在爆炸的所有对象。每次调用kaboom时,我们将把调用的对象添加到这个数组中。当气泡移动完成后,它应该将自己从列表中移除。通过将所有我们想要移动的对象存储在一个数组中,我们可以运行一个单一的setTimeout循环,并同时更新所有下落气泡的位置。因此,我们将避免多个setTimeout竞争处理能力,动画也应该更加流畅。

我们还将添加两个组件:一些默认的重力参数和我们希望气泡下落的距离,在此距离下我们会认为它已经不在屏幕上,不再是功能的一部分。

jquery.kaboom.js

  (function(jQuery){
➊  **var defaults = {**
      **gravity : 1.3,**
      **maxY : 800**
    **};**
➋  **var toMove = [];**
    jQuery.fn.kaboom = function(settings){
    }
  })(jQuery);

默认值为gravitymaxY ➊,toMove ➋将保存下落的 jQuery 对象。

目前,调用kaboom时不会发生任何事情。完整的jquery.kaboom插件如下:

jquery.kaboom.js

  (function(jQuery){
    var defaults = {
      gravity : 1.3,
      maxY : 800
    };
    var toMove = [];
➊  **jQuery.fn.kaboom = function(settings){**
      **var config = $.extend({}, defaults, settings);**
      **if(toMove.length == 0){**
        **setTimeout(moveAll,40);**
      **};**
      **var dx = Math.round(Math.random() * 10) – 5;**
      **var dy = Math.round(Math.random() * 5) + 5;**
      **toMove.push({**
        **elm : this,**
        **dx : dx,**
        **dy : dy,**
        **x : this.position().left,**
        **y : this.position().top,**
        **config : config**
      **});**
    **};**
➋  **var moveAll = function(){**
      **var frameProportion = 1;**
      **var stillToMove = [];**
      **for(var i=0;i<toMove.length;i++){**
        **var obj = toMove[i];**
        **obj.x += obj.dx * frameProportion;**
        **obj.y -= obj.dy * frameProportion;**
        **obj.dy -= obj.config.gravity * frameProportion;**
        **if(obj.y < obj.config.maxY){**
          **obj.elm.css({**
            **top : Math.round(obj.y),**
            **left : Math.round(obj.x)**
          **});**
          **stillToMove.push(obj);**
        **}else if(obj.config.callback){**
          **obj.config.callback();**
     **}**
    **};**
    **toMove = stillToMove;**
    **if(toMove.length > 0)**
        **setTimeout(moveAll,40);**
    **};**
  })(jQuery);

这个插件中有两个主要的循环:jQuery.fn.kaboom ➊,用于将新元素添加到动画队列中,和moveAll ➋,用于处理动画。

让我们先详细看看jQuery.fn.kaboom

  jQuery.fn.kaboom = function(settings){
➊  var config = $.extend({}, defaults, settings);
➋  if(toMove.length == 0){
      setTimeout(moveAll,40);
    };
➌  var dx = Math.round(Math.random() * 10) - 5;
    var dy = Math.round(Math.random() * 5) + 5;
➍  toMove.push({
      elm : $(this),
      dx : dx,
      dy : dy,
      x : $(this).position().left,
      y : $(this).position().top,
      config : config
    });
  };

这个函数启动动画过程,每个对象只会调用一次(也就是说,它不会作为动画循环的一部分运行)。然后,函数为此次kaboom调用设置配置选项➊。该语法创建一个对象,并在父定义中设置默认值(defaults变量),然后用传递的对象中的任何设置覆盖这些默认值。它还会将任何新的名称/值对添加到kaboom将要操作的对象中。

我们查看toMove数组,如果数组为空➋,就设置一个超时调用来运行动画。接着,在dxdy中设置初始的xy速度值➌。这些值在水平方向上介于–5 和 5 像素之间,在垂直方向上(向上)介于 5 和 10 像素之间;两者的单位为每秒像素。然后,我们向toMove数组中添加一个新对象➍。新对象包含 jQuery 元素、它的新创建的速度信息、当前的屏幕位置,以及在此次调用中指定的配置选项。

jQuery.fn.kaboom函数在每次调用$(...).kaboom时运行。如果至少有一个对象正在爆炸,包含moveAll的超时将持续运行。我们来看看moveAll函数的作用:

  var moveAll = function(){
➊  var frameProportion = 1;
➋  var stillToMove = [];
➌  for(var i=0;i<toMove.length;i++){
      var obj = toMove[i];
➍    obj.x += obj.dx * frameProportion;
      obj.y -= obj.dy * frameProportion;
➎    obj.dy -= obj.config.gravity * frameProportion;
➏    if(obj.y < obj.config.maxY){
        obj.elm.css({
          top : Math.round(obj.y),
          left : Math.round(obj.x)
        });
        stillToMove.push(obj);
➐    }else if(obj.config.callback){
        obj.config.callback();
      }
    };
➑  toMove = stillToMove;
    if(toMove.length > 0)
➒    setTimeout(moveAll,40);
  };

我们假设setTimeout每 40 毫秒确实运行一次,因为这是我们指定的值➒;因此,我们将帧率计算为每秒 25 帧➊。如果计算机性能不足(或者正在忙于使用 CPU 资源处理其他操作),并且帧之间的延迟远慢于 40 毫秒,那么这个假设可能会导致动画质量较差。稍后,你将学习如何在不受处理器性能影响的情况下保持动画速度恒定,但当前的解决方案在旧版浏览器中提供了最佳兼容性。

在设置帧率后,moveAll创建一个空数组➋来存储那些在动画帧结束时没有超过最大y值的对象。这里得到的值将成为新的toMove值,用于在下一个帧继续移动。

完成设置工作后,moveAlltoMove数组中的每个元素进行循环➌(即所有当前处于爆炸状态的对象;我们在jQuery.fn.kaboom中填充了这个数组),并获取每个元素的引用,将其存储在obj变量中,obj是一个具有以下属性的对象:

  • obj.elm指向 jQuery 对象

  • dxdy速度值

  • 存储当前位置信息的xy坐标

在循环内部,我们根据物体的xy速度的比例分别改变xy的值 ➍。这还不会影响气泡的屏幕位置,因为我们还没有操作 DOM 元素。该函数还将配置的重力设置添加到物体的垂直速度中 ➎。水平速度应该在整个爆炸效果中保持不变,但物体会加速下落以模拟掉落。接下来,我们检查 ➏ 物体的y值是否超过了我们在默认设置中配置的最大值,或者在调用kaboom时覆盖的最大值。如果没有超过,屏幕元素的位置将设置为当前存储的位置,并将物体添加到stillToMove数组中。另一方面,如果物体已经超过了最大y值,并且在原始kaboom调用中传递了回调函数,moveAll会运行 ➐ 该函数。将函数传入动画并在动画完成时运行该函数非常有用。

最后,我们将toMove ➑的新值设置为stillToMove的内容(也就是所有仍在下落的物体),如果数组中至少包含一个元素,我们将设置一个超时,在 40 毫秒后再次调用相同的函数➒。

现在,当你重新加载游戏并创建一个孤立的物体组时,kaboom 插件应该会使气泡沿屏幕下落。虽然它在我们的游戏上下文中有效,但你也可以使用任何有效的 jQuery 选择器调用它,并产生类似的效果。保留这段代码,以便将来在其他游戏中复用这一效果!

总结

现在,Bubble Shooter的许多部分已经就绪。我们可以发射气泡,这些气泡要么会落入网格,要么会爆掉一组气泡,而且我们可以检测到孤立的气泡组并将其从屏幕上移除。然而,板块上可能会堆满未爆掉的气泡,这是我们仍然需要解决的问题。目前,游戏中也没有办法开始下一关或跟踪分数;这两个都是此类游戏中非常重要的元素。但在完成其他游戏功能之前,我们将深入探讨一些已经编写好的动画在 HTML5 和 CSS 中的实现。

到目前为止,我们已经使用一些相当传统的 HTML、CSS 和 JavaScript 技术实现了所需的功能。大部分情况下,这个游戏应该能在大多数计算机上顺利运行。在下一章,我们将通过将一些动画工作从 JavaScript 转移到 CSS 来提高性能。这一变化将让我们在可能的情况下利用硬件加速,并且我们还将使用一些纯 HTML5 特性来实现更流畅的动画。我们还将使用canvas渲染来实现整个游戏,而不是 DOM 和 CSS,从而展示使用这种方法时的优势和挑战。

进一步练习

  1. 在 第三章的练习中,你修改了 createLayout 以生成不同的网格模式。现在,使用气泡弹出和气泡掉落的代码来测试你的布局。代码是否能正常工作?你的布局模式如何影响游戏的感觉?

  2. 当前的气泡动画由四帧组成。创建你自己的图像版本,并尝试添加更多帧。使用 for 循环来生成额外的 setTimeout 调用,而不是复制粘贴新行。试验不同的超时延迟,调整动画的快慢,看看哪些值能产生最佳效果。

  3. kaboom jQuery 插件将气泡从屏幕底部掉落,但如果你让气泡在撞到底部时反弹,会发生什么呢?修改 jquery.kaboom.js 代码,使气泡反弹而不是掉出屏幕。你需要反转它们的 dy 值,并且在每次反弹时将它们缩小,以模拟一部分反弹能量被吸收;否则,它们会反弹回相同的高度。气泡只有在反弹到屏幕的左边或右边的边缘时才会从 DOM 中移除,因此你还需要确保 dx 值不接近零,否则它们永远不会消失。

第二部分:HTML5 和 Canvas 的增强功能

第五章 CSS 过渡与转换

到目前为止,我们已经使用 HTML、CSS 和 JavaScript 创建了一个简单的游戏:我们可以发射并爆破气泡,用户界面也很响应。我们通过文档对象模型(DOM)操作,并借助大量 jQuery 来实现这一点。

在本章中,我们将探讨 CSS 过渡和转换,它们可以改善游戏性能,并让你创建更广泛的效果,比如旋转和缩放元素。

CSS 的优点

CSS 提供了一组转换和过渡属性,可以用来动画化 CSS 属性的变化,例如元素的 lefttop 坐标。与其像之前那样使用 JavaScript 按帧处理动画,CSS 过渡是在样式表中或附加到 DOM 元素的样式中指定的。动画通过对 CSS 属性进行单一更改来启动,而不是像 JavaScript 动画那样对属性进行多次增量更改。

CSS 动画由浏览器的渲染引擎处理,而不是由 JavaScript 解释器处理,从而为运行其他 JavaScript 代码释放了 CPU 时间,并确保在设备上实现最平滑的动画效果。在具有图形处理器的系统上,效果通常完全由图形处理器处理,这意味着你运行的 JavaScript 代码负担更小,并且可以进一步减少 CPU 的负载,从而提高帧率。因此,动画将在其显示设备上以最高帧率运行。

我们将使用 CSS 为用户界面元素添加一些简单的过渡,然后将我们的 jQuery 动画替换为转换,并在此过程中保持到目前为止所实现的跨浏览器兼容性。

基本的 CSS 过渡

我们将关注的第一个 CSS 动画是过渡。过渡定义了一个对象的样式属性应如何从一个状态变化到另一个状态。例如,如果我们将 DOM 元素的 left 属性从 50 像素更改为 500 像素,它将立即在屏幕上改变位置。但如果我们指定了过渡,我们可以让它逐渐在屏幕上移动。CSS 过渡指定了要动画化的属性或属性集、动画应如何进行,以及动画应该持续多长时间。

过渡通常适用于任何具有数值的 CSS 属性。例如,像前面提到的那样对 left 属性进行动画化是可行的,因为可以计算出开始和结束之间的中间值。其他属性的变化,例如 visibility : hiddenvisibility : visible,不是有效的过渡属性,因为无法计算中间值。然而,我们可以通过将 opacity 属性从 0 动画化到 1 来使元素淡入。

颜色也是有效的动画属性,因为十六进制值也是数字(每个包含三个数字对,每个数字对表示红色、绿色或蓝色),可以从一个值逐渐变化到另一个值。你可以在 developer.mozilla.org/en-US/docs/Web/CSS/CSS_animated_properties/ 找到所有可以使用过渡动画的属性列表。

如何编写过渡

要使用过渡对 div 进行动画,给它添加一个 CSS transition 属性。一个 transition 属性包括以下内容:

  • 应用过渡的 CSS 属性。这些可以是你想要动画的任何有效 CSS 属性,例如 topleftfont-size,或者仅使用 all,它会将过渡应用于所有有效的属性变化。

  • 持续时间。过渡所需的时间(以秒为单位)。

  • 缓动。指示属性在过渡持续时间内变化的速度。例如,某个元素可能会以平滑的速度从一个位置移动到另一个位置,或者它可能在开始时加速,随后在结束时减速,如图 5-1 所示。你也可以对其他你想要改变的属性应用缓动,包括颜色。

    图表显示无缓动和有缓动的运动(动画开始时的缓动和结束时的缓动)。

    图 5-1. 图表显示无缓动和有缓动的运动(动画开始时的缓动和结束时的缓动)。

  • 开始延迟。指定开始过渡前等待的秒数。最常见的值是 0(或为空),意味着立即开始。

我们将像编写其他 CSS 规则一样编写过渡定义,当我们希望过渡发生时,我们将修改要进行动画的 CSS 属性。为了让 div 或其他 HTML 元素在屏幕上平滑移动,我们将 topleft 坐标设置为新的值:

transition: top 1s, left 2s (etc)

作为示例,我们将使“新游戏”按钮向下移动。将以下内容添加到 main.css

main.css

  .button
  {
    transition: ➊all ➋.8s ➌ease-in-out ➍1s;
➎  -moz-transition: all .8s ease-in-out 1s;
    -webkit-transition: all .8s ease-in-out 1s;
    -ms-transition: all .8s ease-in-out 1s;
  }

transition 定义的第一个值 ➊ 指定了过渡应用的属性(或属性)。使用 all 将过渡应用于所有属性,可以把它当作一个通配符。第二个值 ➋ 是过渡的持续时间(以秒为单位)。第三个值 ➌ 是缓动:ease-in-out 会产生一个平滑的过渡,开始时加速,结束时减速。最后,我们添加一个延迟 ➍,在动画开始前等待 1 秒。接下来的三行从 ➎ 开始,提供了相同的规范,但使用了供应商特定的前缀,以支持跨浏览器。这些是旧版浏览器所需的;新版浏览器在标签定义被认为稳定后,会使用无前缀版本。

为了保证你的游戏能在某个浏览器上运行,始终包括正确的厂商特定前缀。只要确保每次更改过渡的属性时,也要在每个浏览器的过渡定义中进行相应更改。

幸运的是,规则很简单:transition的浏览器特定版本只是常规版本的副本,并在前面加上以下前缀之一:

  • -moz-用于 Mozilla 浏览器,如 Firefox

  • -webkit-用于 Webkit 浏览器,如 Chrome 和 Safari

  • -ms-用于微软 Internet Explorer

重新加载页面,然后在 JavaScript 控制台中输入以下内容:

$(".but_start_game").css("top",100)

你应该能看到一个暂停,然后按钮会平滑地滑动到屏幕上方。效果或多或少与animate调用相同,但我们只更改了 CSS 的值。

现在删除.button的 CSS 定义,因为我们将应用一个更有用的效果。

颜色变化按钮

让我们应用过渡效果来增强用户界面!我们将不使用任何 JavaScript 代码来动画化按钮;相反,我们将使用transition定义和你可能熟悉的hover伪类来创建鼠标悬停按钮效果。

首先,我们将为“新游戏”按钮添加一个鼠标悬停状态,通过修改 CSS。现在将以下内容添加到main.css

main.css

  .button
  {
    transition: ➊background-color ➋.3s ➌ease-in-out;
➍  -moz-transition: background-color .3s ease-in-out;
    -webkit-transition: background-color .3s ease-in-out;
    -ms-transition: background-color .3s ease-in-out;
  }
    .button:hover
    {
      background-color: #900;
    }

transition定义中的第一个值 ➊ 指定过渡应用于哪个属性(或哪些属性)。我们将其应用于background-color属性,该属性的写法与标准 CSS 规则完全相同。第二个值 ➋ 是过渡的持续时间,单位为秒。第三个值 ➌ 是缓动函数,再次设置为ease-in-out

其他类型的缓动包括easelinear,或者仅使用ease-inease-out。但所有这些简写实际上都是cubic-bezier的别名,你可以用它来表示任何你喜欢的过渡曲线。cubic-bezier缓动函数接受四个小数值来定义一个图形;例如,

transition: background-color .3s ease-in-out;

与...相同

transition: background-color .3s cubic-bezier(0.42, 0, 0.58, 1.0)

Bézier 曲线通过指定两点的坐标来描述,这两点分别形成曲线开始部分和结束部分的切线。这些点在图 5-2 中显示为 P1 和 P2。

指定 Bézier 曲线的两点是 P1 和 P2。

图 5-2. 指定 Bézier 曲线的两点是 P1 和 P2。

在 CSS 中指定的值是 P1 和 P2 的坐标,它们始终位于 0 和 1 之间。你不会指定 P0 和 P3,因为它们始终是原点(0,0)和(1,1)。P1 和 P2 与垂直轴的夹角决定了曲线的斜率,而从 P0 到 P1 和从 P2 到 P3 的线段长度则决定了曲线的弯曲程度。

除非你需要特定的缓动效果,ease-in-outlinear 通常就足够了。但对于更复杂的过渡,一些在线工具可以帮助你根据可视化图表和输入值创建 cubic-bezier 曲线。其中一个网站是 cubic-bezier.com/,它允许你调整值并观看动画,看看这些数值是如何转换为过渡动画的。

在初始过渡定义之后的三行,是厂商特定的过渡定义,我确保包括了这些定义,以便过渡在不同浏览器中正确运行。CSS 标准仍然被视为一个正在进行的工作,浏览器厂商采用了自己的前缀,以避免在标准最终确定时与实现的方式发生潜在冲突。

我目前使用的单行格式是指定过渡的最紧凑方式,但你也可以分别指定每个属性:

transition-property: background-color;
transition-duration: .3s;
transition-timing-function: ease-in-out;

我建议大多数时候坚持使用紧凑的方式。否则,你将需要所有的 CSS 标准行以及每行的三个厂商特定副本,这会很快使你的样式表变得凌乱。

重新加载页面并将鼠标悬停在“新游戏”按钮上。你应该看到按钮的颜色从浅红色渐变到深红色。这是一个不错的效果,而且你没有写任何 JavaScript!不过,使用纯 CSS,你还能做更多的效果。

基本的 CSS 变换

我们要看的第二个强大功能是 CSS 变换。变换允许你操控对象的形状。在大多数浏览器中,你可以在二维或三维空间中变换一个对象,并可以按任何可以用三维矩阵描述的方式进行倾斜、扭曲和旋转。你可以通过过渡来动画化变换,或者让变换独立存在;例如,为了展示一个按钮的倾斜角度,你可以让用户看到它旋转,或者你也可以直接渲染一个倾斜的按钮。

如何编写变换

一些简单的 CSS 变换包括:

  • 按 (x, y) 或者甚至按 (x, y, z) 坐标在三维空间中进行平移

  • 沿 xyz 轴的维度进行缩放

  • 沿任意 xyz 轴旋转

  • 沿 x 轴或 y 轴的倾斜

  • 添加 3D 透视效果

你可以通过二维或甚至三维矩阵来进行变换。通过矩阵进行变换涉及一些数学计算。如果你想更深入地探讨,可以在线找到一些参考资料,比如developer.mozilla.org/en-US/docs/Web/CSS/transform/

缩放按钮

在本节中,我们将通过在当前的颜色变化上添加一个放大效果,使“新游戏”按钮变得更具动态感。请在 main.css 中的 .button:hover 定义中添加以下内容:

main.css

  .button:hover
  {
    background-color: #900;
➊  **transform: scale(1.1);**
    **-moz-transform: scale(1.1);**
    **-webkit-transform: scale(1.1);**
    **-ms-transform: scale(1.1);**
  }

整个变换主要包含在一行 transform 代码中 ➊。变换定义为按 1.1 的比例进行缩放——即大小增加 10%。接下来的三行做了相同的事情,但使用了你在 transition 定义中使用的相同供应商特定的前缀。

我们只需要缩放“新游戏”按钮,因此重新加载页面后,再次将鼠标悬停在按钮上。缩放应该会生效,但不会像平滑动画那样进行。尽管颜色仍然会根据鼠标悬停逐渐变化,但按钮的大小会一步到位地跳变。我们将修改过渡定义,以便它同时应用于变换和背景颜色。

为了完成这个任务,我们可以简单地修改 .button 定义,使得 transition 属性影响每个 CSS 属性:

transition: **all** .3s ease-in-out;

这个定义将 ease-in-out 效果应用于按钮的所有可以应用过渡的 CSS 属性。现在,如果这些属性中的任何一个在 DOM 渲染后发生变化,按钮将在该属性上应用 300 毫秒的过渡动画效果。但如果你不想让所有按钮动画以相同的速度发生呢?

在这种情况下,你可以通过添加逗号分隔的定义来指定多个属性:

transition: background-color .2s ease-in-out**, transform 0.2s ease-in-out;**

这个解决方案还最小化了副作用,如果我们想要动态改变其他 CSS 属性而不让它们自动动画化时,会更加有效。

当你在 CSS 中对单个 transform 属性应用过渡时,仍然需要在每个 transition 定义中指定供应商特定的版本。因此,完整的按钮定义需要是这样的:

.button
{
  transition: background-color .3s ease-in-out**, transform .2s ease-in-out**;
  -moz-transition: background-color .3s ease-in-out**, -moz-transform .2s**
**ease-in-out**;
  -webkit-transition: background-color .3s ease-in-out**, -webkit-transform .2s**
**ease-in-out**;
-ms-transition: background-color .3s ease-in-out**, -ms-transform .2s ease-**
**inout**;
}

main.css 中进行此更改,重新加载页面,再次将鼠标悬停在按钮上。现在,背景颜色和缩放应该都会平滑过渡。

CSS 过渡和变换对于简单的动画非常有用,尤其是在用户界面元素(如按钮)上的鼠标悬停效果。然而,它们不仅仅用于给用户界面添加一些亮点:我们还可以用它们来动画化精灵,包括游戏中发射的气泡。

用 CSS 过渡替代 jQuery 动画

现在,当玩家发射气泡时,气泡会离开发射点,并沿直线朝目标移动。任何发射的气泡都会遵循一种简单到足以让 CSS 过渡轻松处理的路径,切换到这种方式将减轻一些 JavaScript 的负担。

我们为按钮悬停效果使用的硬编码 CSS 过渡,过渡定义在样式表中,但在气泡移动时不会生效,因为过渡的持续时间需要根据气泡的移动距离来改变。目前,气泡以每秒 1000 像素的速度移动。因此,例如,如果我们希望气泡移动 200 像素,则持续时间需要设置为 200 毫秒。为了处理这个可变的持续时间,我们不会在样式表中指定 CSS 过渡,而是在运行时通过 JavaScript 来应用它们。

使用 jQuery 设置 CSS 过渡的语法与设置其他 CSS 属性相同,但我们需要为属性名添加浏览器前缀。幸运的是,在这个任务中我们不必为每个浏览器编写四个版本的过渡。Modernizr 可以为我们处理这些前缀,这实际上使得在 JavaScript 中创建 CSS 过渡比在样式表中更容易!

然而,并非所有较旧的浏览器都支持过渡,因此在ui.js中,我们首先会检查是否支持 CSS 动画,如果不支持,则回退到 jQuery 动画。除非你确定所有目标浏览器都支持 CSS 过渡,否则最好构建一个回退选项。

这个 CSS 动画的代码包括三个步骤:

  1. 将过渡 CSS 属性添加到元素中,告诉它以多快的速度移动以及应用过渡的属性。

  2. topleft属性更改为我们希望气泡停止的位置坐标。

  3. 一旦气泡到达目标位置,移除 CSS 过渡定义。

按照以下方式修改ui.js中的fireBubble

ui.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.ui = (function($){
    var ui = {
      --*snip*--
      fireBubble : function(bubble,coords,duration){
➊      **var complete = function(){**
➋        **if(bubble.getRow() !== null){**
➌          **bubble.getSprite().css(Modernizr.prefixed("transition"),"");**
            **bubble.getSprite().css({**
              **left : bubble.getCoords().left - ui.BUBBLE_DIMS/2,**
              **top : bubble.getCoords().top - ui.BUBBLE_DIMS/2**
            **});**
          **};**
        **};**
➍      **if(Modernizr.csstransitions){**
➎        **bubble.getSprite().css(Modernizr.prefixed("transition"),"all " +**
            **(duration/1000) + "s linear");**
          **bubble.getSprite().css({**
            **left : coords.x - ui.BUBBLE_DIMS/2,**
            **top : coords.y - ui.BUBBLE_DIMS/2**
          **});**
➏        **setTimeout(complete,duration);**
➐      **}else{**
          bubble.getSprite().animate({
              left : coords.x - ui.BUBBLE_DIMS/2,
              top : coords.y - ui.BUBBLE_DIMS/2
            },
            {
              duration : duration,
              easing : "linear",
              complete : **complete**
            });
        **}**
      },
      --*snip*--
    };
    return ui;
  } )(jQuery);

我们将动画后的函数——也就是我们希望 jQuery 在animate调用完成后执行的那个——移到了它自己的命名定义中 ➊,并将其赋值给一个变量。这个函数确保如果气泡还没有从屏幕上消失,它最终会被定位到棋盘网格内。这个函数与之前的版本相同,首先我们检查气泡是否有行定义 ➋。如果行定义为空,说明气泡错过了棋盘或者触发了弹出事件。否则,气泡需要成为主棋盘的一部分。在这种情况下,我们还会移除 ➌ 过渡定义,并将气泡移动到最终位置。因此,如果将来我们对气泡应用任何 CSS 更改,就不会应用不必要的过渡。

当调用fireBubble时,我们使用 Modernizr ➍检查是否支持 CSS 过渡。如果支持,我们可以将过渡 CSS 添加到气泡元素 ➎。过渡定义将采取以下形式:

transform: all [duration]s linear

Modernizr.prefixed("transition")会添加任何必要的浏览器前缀。我们将过渡时长设置为传入时长的相同值,但将其除以 1000 以将毫秒转换为秒 ➎。

最后,如果我们确实添加了过渡,我们会设置一个超时 ➏,在过渡结束时调用complete。如果浏览器不支持 CSS,我们不需要setTimeout调用,因为在这种情况下,我们将使用 jQuery 的animate函数,它接受一个回调函数,在动画完成后执行。我们需要将complete函数作为参数传递给该animate调用 ➐,但本质上,jQuery 版本的动画与之前的相同。

刷新页面,触发一个事件,大多数情况下你会发现游戏没有变化。 但这只是意味着你的设备可能已经以足够高的帧率显示了我们之前要求它显示的 jQuery 动画,以至于它与 CSS 版本无法区分。在幕后,这个动画现在被交给了图形处理器(如果你的设备有的话),这样 JavaScript 就不需要处理计算负载。在有许多移动元素的游戏中,你刚刚做的更改可能会带来明显的性能提升。

CSS 过渡的缺点

如果 JavaScript 必须逐帧地处理动画,为什么不尽可能使用 CSS 过渡呢?虽然 CSS 过渡提供了许多好处,特别是在平滑动画方面,但它们在游戏中的有用性通常受到控制能力不足的限制。

随着向单个元素添加更多动画,CSS 过渡变得更加繁琐。例如,如果你想让一个元素在 1 秒内移动 100 像素,同时在 2 秒内调整它的大小 10 像素,你需要为每个 CSS 属性指定不同的过渡。更重要的是,在移动过渡结束时,你需要保留 CSS 定义,以便调整大小动画能够继续,如果你需要再次移动该元素,这将特别困难。

过渡的第二个缺点是,尽管缓动可以改变动画的呈现方式,但运动必须是直线的。例如,在角色跳跃越过某物的动画中,运动沿曲线进行,可能是通过对许多小的直线段进行动画处理来实现的。但在这种情况下,你不如使用 JavaScript 来处理整个动画。

一旦启动,CSS 过渡就无法进行查询和更改。浏览器会处理过渡,并在你设置 CSS 值后立即更新元素的位置。元素可能会在过渡过程中呈现到达目标的一半位置,但 DOM 会报告它已经完成了移动。因此,在动画结束之前,无法查询元素的当前位置。如果你想要应用方向的改变,你需要进行新的计算并重写你的 CSS 过渡。

例如,如果你告诉一个元素将其左侧位置从 50 像素变化到 250 像素,持续时间为 2 秒,但在 1 秒后你需要将其移到屏幕的不同位置,你首先需要计算它在 1 秒后在屏幕上的位置。DOM 会报告它的左侧位置是 250 像素,但我们知道它正处于动画的中间点,这在大多数情况下意味着它应该是 150 像素。但如果你指定了沿着三次 Bézier 曲线的缓动效果,元素可能并不处于中点,实际上可能离中点相当远。你需要编写一个方程式来计算当前的左侧坐标。这个例子比大多数简单,因为我们让元素停在了动画的中间,但在任何应用了缓动效果并且位于动画路径的其他位置时,计算元素可能在屏幕上的绘制位置都不是一件简单的任务。

将这个例子与 jQuery 动画进行对比,你只需在 1000 毫秒后调用 .stop 方法即可让元素停下。使用 jQuery,你甚至可以应用一个新的 animate 方法,将一个精灵设置到全新的路径上,而无需等待之前的动画完成。CSS 转换和过渡对于用户界面操作或相对简单的直线运动非常有效,但它们并不提供我们在许多游戏动作中所需的灵活性。

总结

你已经了解了 CSS 过渡的简便和强大,但也意识到它们在游戏中的应用可能会受到限制。你还简要地了解了 CSS 转换,它可以与过渡结合使用,为按钮或其他 HTML 元素添加特效。

CSS 过渡相比于 JavaScript 动画的主要优势之一是渲染速度,但不幸的是,除非是最简单的动画,否则它们并不容易使用。在下一章中,我们将研究 canvas 元素,看看如何以比基于 DOM 的开发更快且更有控制力的方式为游戏添加动画。

进一步练习

  1. 使用我们为“新游戏”按钮制作的 CSS 过渡动画示例,尝试一些 Bézier 曲线的缓动效果。考虑一下不同的值在游戏动画中可能的应用。

  2. 创建一个转换矩阵,将元素从左翻转到右,使其看起来像镜像。

  3. 常见的 2D CSS 转换包括平移、旋转、缩放和倾斜。你可以用矩阵变换来重现其中哪些效果,哪些不能重现?

第六章 渲染 Canvas 精灵

直到现在,我们一直使用基于 DOM 的方法构建 Bubble Shooter,通过使用 HTML 元素作为游戏对象,这些对象通过 CSS 进行样式化和定位,并由 JavaScript 进行操作。在这一章中,我们将重新构建 Bubble Shooter,使大部分游戏区域渲染到 Canvas 上,而不是使用 DOM。我们的游戏对话框将继续使用 HTML 和 CSS。

Canvas 渲染允许我们实现一些通常在基于 DOM 的开发中无法实现的图形效果,而且它通常能提供更快的渲染速度。为了在 Bubble Shooter 中使用 Canvas 渲染,我们需要学习如何将整个场景渲染到 Canvas 上,保持状态,并执行逐帧动画。

对于不支持 canvas 元素的设备,我们将保留现有的 DOM 渲染代码,并为更现代的浏览器提供 Canvas 渲染的渐进增强。我们这么做是为了演示如何为 Canvas 和基于 DOM 的动画编码的原则,并突出这两种方法的区别。

检测 Canvas 支持

Modernizr 可以帮助我们检测 Canvas 特性,这样我们就不必记住多个跨浏览器的情况。我们只需要为 Canvas 版本加载几个额外的 JavaScript 文件,并且不会删除任何文件。为了检测 Canvas 并加载正确的文件,我们需要在 index.html 中的 Modernizr.load 里增加一个额外的节点,这将检查 Canvas 支持情况,如果支持,将从数组中加载 JavaScript 文件。在加载 game.js 之前,添加以下内容:

index.html

},
**{**
  **test: Modernizr.canvas,**
  **yep: ["_js/renderer.js","_js/sprite.js"]**
**},**
{
  load: "_js/game.js",
  complete: function(){
    $(function(){
      var game = new BubbleShoot.Game();
      game.init();
    })
 }
**}**]);

Modernizr.canvas 的值,即 test 查找的参数,将是 truefalse。如果为 true,则加载 yep 中列出的两个文件;如果为 false,则不会发生任何新的操作。

_js 文件夹中为 renderer.jssprite.js 创建空文件。Renderer 对象将在每一帧绘制游戏状态,而 Sprite 类将执行我们迄今为止使用 jQuery 完成的许多操作。我们希望 Renderer 负责将像素绘制到 Canvas 上,而不是将游戏逻辑与其混合;同样,我们将尽量将状态信息保留在相关的对象内。这样的方法让我们可以更轻松地在 Canvas 和 DOM 渲染之间切换,具体取决于我们认为最适合游戏的方式。

绘制到 Canvas

使用 HTML5 的 Canvas 特性,你可以构建类似 Flash 游戏甚至本地应用程序级别的游戏。你将 canvas 元素放入文档中的方式与其他元素(如 <div><img>)相同,但与该元素的交互方式使其与众不同。在 Canvas 内部,你可以精确控制像素,并且可以绘制到单个像素,读取其值并对其进行操作。你可以编写 JavaScript 代码来生成街机射击游戏,甚至是 3D 游戏,而这些是基于 DOM 的方法难以复制的。

DOM 与 Canvas

HTML 主要是一种信息格式;CSS 则作为一种格式化信息的方式引入。使用这两种技术创建游戏实际上是一种误用,像 泡泡射手 这样的游戏之所以可行,很大程度上是因为浏览器厂商致力于提高性能。许多在排版文档时非常有用的过程,例如确保文本区域不重叠或文本绕过图像,都是我们在排版游戏时不需要的。作为游戏开发者,我们要负责确保屏幕布局良好,但不幸的是,浏览器仍然会在后台执行所有这些检查。

例如,在 DOM 中添加或删除元素可能是一项相对昂贵的操作,涉及到处理性能的问题。原因在于,如果我们添加或删除某些内容,浏览器需要检查这些更改,以确保它们不会对文档的其他部分产生连锁反应。如果我们在网站上工作,比如一个扩展菜单,我们可能希望浏览器在我们添加更多元素时将导航区域推下去。然而,在游戏中,我们更可能使用 position: absolute,而我们肯定不希望新增或删除元素时,周围的所有内容都被重新定位。

相反,当浏览器看到 canvas 元素时,它只看到了一个图像。如果我们更改 canvas 的内容,只有内容会发生变化。浏览器不需要考虑这一变化是否会对文档的其他部分产生连锁效应。

与 CSS 和 HTML 不同,canvas 让你无法依赖浏览器跟踪屏幕上物体的位置。没有任何自动处理图层或背景渲染的机制,因为 canvas 输出的是一个平面图像供浏览器显示。如果使用 CSS 进行精灵动画和移动像是在布告墙上移动纸张,那么 canvas 动画就更像是使用白板:如果你想改变或移动某个东西,你必须擦除一个区域然后重新绘制它。

Canvas 渲染与 CSS 布局的另一个不同之处在于,元素的定位不能由浏览器处理。例如,在现有的基于 DOM 的系统中,我们可以使用 CSS 过渡来将气泡从它的发射位置平滑地移动到我们希望它出现在板面布局的任何位置。做到这一点只需要几行代码。

另一方面,Canvas 渲染要求我们逐帧动画,方式类似于 jQuery 的内部工作原理。我们必须计算气泡在路径上的位置,并在每次帧更新时在该位置绘制它。

单独来说,使用 JavaScript 在画布上进行动画制作不会比在没有 jQuery 或 CSS 过渡的情况下使用 DOM 进行 JavaScript 动画更为困难,但这个过程变得更加复杂,因为如果我们想要更改画布的内容,我们需要删除像素并重新绘制它们。虽然有一些优化重绘过程的方法,但基本的方法是为每个动画帧重新绘制整个画布。这意味着,如果我们想要在画布上移动一个物体,我们不仅要渲染我们想要移动的物体,还可能需要渲染场景中的每个物体。

我们将使用画布绘制游戏板和当前的气泡,但某些组件,如对话框,作为 DOM 元素会更好。用户界面组件通常作为 DOM 元素更新更为方便,而且浏览器通常使用 HTML 渲染文本时比在 canvas 元素中渲染文本更为精确。

现在我们决定使用画布系统来渲染游戏,接下来让我们看看这将涉及哪些内容。关键任务是渲染图像并维护每个气泡的状态,以便我们知道哪些气泡是静止的,哪些在移动,哪些处于爆破的不同阶段。

图像渲染

任何你想绘制到画布上的图像都必须预加载,这样它才能在绘制时可用;否则,什么也不会显示。为此,我们将在 JavaScript 中创建一个内存中的 Image 对象,设置图像源为精灵图,并附加一个 onload 事件处理程序,以便在图像加载完成时知道。目前,只要在 game.js 中运行 init 函数,并且点击“新游戏”按钮时触发 startGame 函数,游戏就可以进行:

$(".but_start_game").bind("click",startGame);

我们仍然希望这样做,但我们不希望在精灵图像加载完成之前发生。这将是我们要处理的第一个任务。

canvas 元素

接下来,我们需要了解如何将图像绘制到画布上。canvas 元素是一个 HTML 元素,就像其他任何元素一样:它可以插入到 DOM 中,可以应用 CSS 样式,行为也与图像类似。例如,要创建一个 canvas 元素,我们需要在 index.html 中添加以下内容:

<canvas id="game_canvas " width="1000" height="620"></canvas>

这将创建一个 canvas 元素,宽度为 1000 像素,高度为 620 像素。这些尺寸很重要,因为它们确定了构成画布的像素数量。然而,我们还应该在 CSS 中设置这些尺寸,以确定画布在页面上显示的大小:

#game_canvas
{
  width: 1000px;
  height: 620px;
}

就像图像可以按比例渲染一样,canvas元素也可以缩放。通过将 CSS 尺寸设置为与 HTML 属性相同的值,我们确保画布以 1:1 的比例绘制。如果我们省略了 CSS,画布将按照属性中指定的宽度和高度渲染,但最好在样式表中指定布局尺寸。这样不仅有助于代码的可读性,还能确保如果画布的内部尺寸发生变化,页面布局不会被打破。

要使用 JavaScript 将图像绘制到画布上,首先需要获取一个上下文,这是你用来操作画布内容的对象,通过getContext方法获得。上下文告诉浏览器我们是在处理二维空间还是三维空间。你可以写类似下面的代码来指示你要在二维空间中工作,而不是三维空间:

document.getElementById("game_canvas").getContext("2d");

或者使用 jQuery 来编写如下:

$("#game_canvas").get(0).getContext("2d");

请注意,上下文是 DOM 节点的属性,而不是 jQuery 对象的属性,因为我们通过get(0)调用从 jQuery 集合中获取第一个对象。我们需要 DOM 节点,因为基本的 jQuery 库不包含任何处理canvas元素的特殊函数。

现在,为了将图像绘制到画布上,我们使用上下文对象的drawImage方法:

document.getElementById("game_canvas").getContext("2d").
drawImage(imageObject,x,y);

或者再使用 jQuery 来编写如下:

$("#game_canvas").get(0).getContext("2d").drawImage(imageObject,x,y);

传递给drawImage的参数是Image对象,然后是绘制图像的xy坐标。这些是相对于画布上下文原点的像素。默认情况下,(0,0)是画布的左上角。

我们还可以使用clearRect方法清除画布上的像素:

$("#game_canvas").get(0).getContext("2d").clearRect(0, 0, 1000, 620);

clearRect命令会清除从左上角(前两个参数)到右下角(后两个参数)的所有画布像素。虽然你可以只清除你想改变的画布区域,但通常更容易清空整个画布并在每一帧重新绘制它。再次强调,坐标是相对于上下文原点的。

上下文维护了有关画布的多个状态属性,例如当前的线条粗细、线条颜色和字体属性。对于绘制精灵来说最重要的是,它还维护着上下文原点的坐标和旋转角度。事实上,你可以通过两种方式在画布的固定位置绘制图像:

  • xy坐标传入drawImage函数。

  • 移动上下文原点并在原点处绘制图像。

在实践中,使用任何一种方法都会得到相同的结果,但通常最好将上下文的原点移动平移。如果你想以角度将图像绘制到画布上,并不是图像本身旋转,而是画布上下文在绘制图像之前先旋转。

旋转画布

画布始终围绕其原点旋转。如果你想围绕图像的中心旋转图像,首先将画布原点平移到图像中心的新原点。然后按你希望旋转图像的角度旋转画布但方向与你想应用于对象的旋转相反。接着像往常一样绘制图像,旋转画布回到新原点的零度角度,最后将画布平移回初始原点。图 6-1 展示了这一过程是如何工作的。

例如,要绘制一个宽度为 100 像素的图像,位于坐标(100,100),并围绕其中心旋转 30 度,你可以写出以下代码:

➊ var canvas = $("#game_canvas").get(0);
➋ var context = canvas.getContext("2d");
➌ context.clearRect(0, 0, canvas.width, canvas.height);
➍ context.translate(150, 150);
➎ context.rotate(Math.PI/6);
➏ context.drawImage(imageObject, -50, -50);
➐ context.rotate(-Math.PI/6);
➑ context.translate(-150, -150);

将旋转后的图像绘制到画布上

图 6-1. 将旋转后的图像绘制到画布上

这段代码获取画布➊和上下文➋,然后清空画布,准备绘制➌。接下来,我们将原点平移到我们想要绘制图像的坐标位置➍,但我们还需要将图像的宽度和高度的一半添加到平移值中,因为我们将把图像的中心绘制到新的原点。

下一步是添加旋转➎,但请记住我们旋转的是上下文,而不是图像。角度是以弧度而不是度数来指定的。图像被绘制在(-50,-50)➏,这意味着图像的中心绘制在上下文原点,然后上下文被旋转回去➐,再进行平移➑。最后两步很重要,因为上下文会保持状态,因此接下来的任何操作都将基于旋转后的坐标进行。通过反转旋转和平移,我们让画布保持在与最初相同的状态。

如果你不想记得旋转和翻译画布回到其原点,可以通过在改变图像之前保存上下文并在之后重置上下文来简化整个过程:

   var canvas = $("#game_canvas").get(0);
   var context = canvas.getContext("2d");
   context.clearRect(0, 0, canvas.width, canvas.height);
➊ context.save();
   context.translate(150, 150);
   context.rotate(Math.PI/6);
   context.drawImage(imageObject, -50, -50);
➋ context.restore();

调用context.save➊保存当前的上下文状态,但需要注意,它并不会保存画布内的像素数据。然后context.restore➋会将上下文状态恢复为之前保存的状态。

这些原则是我们绘制完整图像到画布上并再将其移除所需的全部内容,但如果要绘制气泡,我们每次只需要绘制精灵图中的一小部分。

画布的宽度和高度

画布有自己的宽度和高度设置,在创建canvas元素时指定这些设置非常重要。你可以使用 CSS 来决定画布在屏幕上显示的尺寸,但它们可能与画布内部实际渲染的像素数不匹配。在我们的例子中,我们会将这两个设置保持一致,这样在画布上绘制一个像素就会显示一个像素。

如果我们将 canvas 元素的宽度和高度设置为当前的两倍,DOM 元素仍然会占用页面上的相同空间,因为我们的 CSS 定义如此。画布与 CSS 的交互方式与图像相同:宽度和高度在样式表中指定,但画布(或图像)可能更大或更小。结果是,我们绘制的图像仅占画布的四分之一,并且看起来是原始大小的四分之一。这是因为画布像素在渲染时被缩放到屏幕像素。尝试将 index.html 中的 canvas 定义更改为以下内容,看看会发生什么:

<canvas id="game_canvas" width="2000" height="1240"></canvas>

canvas 元素不会因为 CSS 规则而在屏幕上显得更大。相反,CSS 定义的每个像素将在画布上表示为 4 个像素。在大多数桌面浏览器中,1 个 CSS 像素与 1 个屏幕像素是相同的,所以将画布尺寸设置为比 CSS 中定义的更大的值没有太大意义。然而,现代设备,特别是移动设备,已经在渲染方面变得非常精密,具备了所谓的更高像素密度。这使得设备能够渲染更高分辨率的图像。你可以在 www.html5rocks.com/en/tutorials/canvas/hidpi/ 阅读更多关于像素密度的内容。

当你在使用画布和 CSS 一起工作时,你需要记住你正在使用的缩放比例。如果你在画布内工作,那么重要的是画布的尺寸,正如其 HTML 属性所指定的那样。当你在画布周围(或可能甚至在画布上方)使用 CSS 元素时,你将使用 CSS 像素尺寸。例如,要在宽度为 2000 像素、高度为 1240 像素的画布右下角绘制图像,你可以使用类似这样的代码:

$("#game_canvas").get(0).getContext("2d").drawImage(imageObject,2000,1240);

但是,要将 DOM 元素放置在右下角,你将使用坐标 (1000, 620),如下所示的 CSS:

{
  left: 1000px;
  top: 620px;
}

如果可能的话,通常最好保持屏幕显示画布的大小(在 CSS 中设置)与画布的宽度和高度定义相同,这样画布渲染器就不需要尝试缩放像素。但是,如果你目标设备的像素密度较高(例如苹果的 Retina 显示器),你可以通过尝试增加画布中像素的数量来提高图形的质量。

精灵渲染

我们不能像在基于 DOM 的系统中那样使用背景图片和位置偏移来渲染气泡精灵。相反,我们需要将气泡精灵作为图片绘制到画布上。记住,精灵图像文件包含了四种气泡颜色,在静止和弹出的状态下都有。例如,在图 6-2 中显示的精灵图像中,如果我们想将一个蓝色气泡绘制到画布上,我们只关心图像中被虚线框住的部分。为了只选择这部分图像,我们将使用可以传递给画布上下文的 drawImage 方法的剪裁参数。

绘制蓝色气泡到画布上所需的剪裁边界

图 6-2. 绘制蓝色气泡到画布上所需的剪裁边界

如果我们想绘制气泡在被弹出的第一阶段,我们将把剪裁区域向右移动。这与我们在 DOM 版本中显示气泡的方式类似,不同之处在于,我们不会让 div 元素的边界定义剪裁边界,而是直接在 JavaScript 中指定这些边界。

要将剪裁后的图像绘制到画布上,只需向 drawImage 方法添加几个参数。之前,我们只用三个参数(Image 对象和 xy 坐标)调用 drawImage,但现在可以传递更多参数来剪裁图像。drawImage 接受的完整参数集如下:

context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height);

参数如下:

  • imgImage 对象。

  • sxsy。剪裁图像相对于图像原点的 xy 坐标。对于静止状态下的蓝色气泡,这些值分别为 0 和 50。

  • swidthsheight。剪裁区域的宽度和高度。对于我们的气泡精灵图,两个值都将为 50。

  • xy。绘制图像在画布上的坐标,相对于画布上下文原点。

  • widthheight。要绘制的图像的宽度和高度。我们可以使用这些参数来缩放图像,或者如果希望图像按 1:1 的比例绘制,则可以省略它们。

例如,要在画布的坐标 (200,150) 上绘制图 6-2 中突出显示的蓝色气泡,我们将使用以下代码:

$("#canvas").get(0).getContext("2d").drawImage(spriteSheet,0,50,50,50,200,150,
50,50);

这行代码假设精灵 Image 对象命名为 spriteSheet,且精灵的宽度和高度均为 50 像素。

定义和维护状态

在基于 DOM 的游戏代码版本中,我们不需要考虑气泡的状态;我们只需使用超时队列事件和动画/回调链。一旦气泡被绘制到屏幕上的固定位置,我们就保持它的状态,除非需要进行修改。气泡会一直在同一位置绘制,直到我们告诉浏览器做其他事情。

但是,当我们切换到画布渲染时,我们需要在每一帧重绘时为每个气泡渲染正确的精灵。我们的代码必须追踪屏幕上所有气泡的状态,无论它们是移动、爆炸、下落还是静止。每个 bubble 对象将追踪其当前状态以及在该状态中停留的时间。我们需要这个持续时间来绘制爆炸动画的帧。Board 对象目前跟踪主要布局中的气泡,我们需要对其进行扩展,以便也能追踪那些正在爆炸、下落或发射的气泡。

准备状态机

为了保持气泡的状态,我们首先会创建一组常量,用来表示气泡的状态。这就叫做使用 状态机,随着游戏复杂度的增加,你可能会发现它越来越有用。使用状态机的基本原则,在本游戏中的应用如下:

  • 一个气泡可以处于多种状态,例如移动、爆炸或下落。

  • 气泡在游戏中的反应将取决于它所处的状态。例如,我们不希望发射的气泡与正在爆炸的气泡发生碰撞。

  • 气泡的显示方式可能取决于其状态,特别是在它正在爆炸时。

  • 一个气泡一次只能处于一种状态;它不能同时处于爆炸状态和被爆炸状态,或者同时处于爆炸状态和下落状态。

一旦设置好状态机,我们就能知道在任何给定情况下该如何处理气泡。一些状态的变化是由用户操作引起的,例如当他们发射气泡时,但我们也会记录气泡进入某个状态时的时间戳。因此,我们可以自动判断气泡何时应该从一个状态转移到另一个状态,例如在碰撞后,我们正处于爆炸过程中。

注意

一般来说,即使你认为你的游戏相对简单,使用状态机来管理你可能还没想到的复杂性是值得的。

将以下内容添加到 bubble.js

bubble.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Bubble = (function($){
➊  **BubbleShoot.BubbleState = {**
      **CURRENT : 1,**
      **ON_BOARD : 2,**
      **FIRING : 3,**
      **POPPING : 4,**
      **FALLING : 5,**
      **POPPED : 6,**
      **FIRED : 7,**
      **FALLEN : 8**
    **}**;
    var Bubble = function(row,col,type,sprite){
      var that = this;
➋    **var state;**
      **var stateStart = Date.now();**
      **this.getState = function(){ return state;};**
➌    **this.setState = function(stateIn){**
        **state = stateIn;**
➍      **stateStart = Date.now();**
      **};**
➎    **this.getTimeInState = function(){**
        **return Date.now() - stateStart;**
      **};**
      --*snip*--
    };
    Bubble.create = function(rowNum,colNum,type){
      --*snip*--
    };
    return Bubble;
  })(jQuery);

这些新增内容使我们能够存储和检索气泡的当前状态 ➋,该状态将是类顶部的八个状态之一 ➊。每当我们更改气泡的状态 ➌时,我们还会记录它进入该状态时的时间戳 ➍。一旦我们确定气泡在当前状态中停留的时间 ➎,我们就可以确定需要绘制的内容。例如,气泡在 爆炸 状态中停留的时间决定了显示哪个爆炸序列的帧。

实现状态

每个气泡可以有以下状态之一,我们需要实现这些状态:

CURRENT 等待发射。
ON_BOARD 已经是棋盘显示的一部分。
FIRING 向棋盘或屏幕外移动。
POPPING 正在爆裂。这将显示爆裂动画的一个帧。
FALLING 一个孤立的气泡正在从屏幕上掉落。
POPPED 完成POPPING。被爆裂的气泡不需要渲染。
FIRED FIRING后错过了棋盘显示。一个发射的气泡不需要被渲染。
FALLEN 完成FALLING并掉出屏幕。一个掉落的气泡不需要渲染。

游戏开始时棋盘上显示的气泡最初处于ON_BOARD状态,但所有其他气泡将从CURRENT状态开始,并转移到其他状态,如图 6-3 所示。

我们将在Game中添加几个数组来跟踪这些状态。在类的顶部,添加:

game.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
    var Game = function(){
      var curBubble;
      var board;
      var numBubbles;
➊    **var bubbles = [];**
      var MAX_BUBBLES = 70;
      this.init = function(){
        --*snip*--
      };
      var startGame = function(){
        $(".but_start_game").unbind("click");
          numBubbles = MAX_BUBBLES
          BubbleShoot.ui.hideDialog();
          board = new BubbleShoot.Board();
➋        **bubbles = board.getBubbles();**
          curBubble = getNextBubble();
        BubbleShoot.ui.drawBoard(board);
        $("#game").bind("click",clickGameScreen);
      };
      var getNextBubble = function(){
        var bubble = BubbleShoot.Bubble.create();
➌      **bubbles.push(bubble);**
➍      **bubble.setState(BubbleShoot.BubbleState.CURRENT);**
        bubble.getSprite().addClass("cur_bubble");
        $("#board").append(bubble.getSprite());
        BubbleShoot.ui.drawBubblesRemaining(numBubbles);
        numBubbles--;
        return bubble;
      };
      --*snip*--
    };
    return Game;
  })(jQuery);

这个新数组 ➊ 将包含游戏中所有的气泡,包括在棋盘上的和不在棋盘上的。最初,每个气泡都是棋盘的一部分,因此可以使用棋盘内容来填充数组 ➋。每次调用getNextBubble时,准备发射的气泡需要被添加 ➌,并将其状态设置为CURRENT ➍。

显示气泡状态的流程图

图 6-3. 显示气泡状态的流程图

board.getBubbles是一个新方法,它将返回棋盘上所有行和列中的气泡,作为一个单一的平面数组,因此请将其添加到board.js中:

board.js

var BubbleShoot = window.BubbleShoot || {};
BubbleShoot.Board = (function($){
  var NUM_ROWS = 9;
  var NUM_COLS = 32;
  var Board = function(){
    var that = this;
    --*snip*--
    **this.getBubbles = function(){**
      **var bubbles = [];**
      **var rows = this.getRows();**
      **for(var i=0;i<rows.length;i++){**
        **var row = rows[i];**
        **for(var j=0;j<row.length;j++){**
          **var bubble = row[j];**
          **if(bubble){**
            **bubbles.push(bubble);**
          **};**
        **};**
      **};**
      **return bubbles;**
    **};**
    return this;
  };
  --*snip*--
  return Board;
})(jQuery);

我们还需要将棋盘上气泡的状态设置为ON_BOARD,因此请在同一文件中的createLayout函数中进行此更改:

var BubbleShoot = window.BubbleShoot || {};
BubbleShoot.Board = (function($){
  var NUM_ROWS = 9;
  var NUM_COLS = 32;
  var Board = function(){
    --*snip*-
  };
  var createLayout = function(){
    var rows = [];
    for(var i=0;i<NUM_ROWS;i++){
      var row = [];
      var startCol = i%2 == 0 ? 1 : 0;
      for(var j=startCol;j<NUM_COLS;j+=2){
        var bubble = BubbleShoot.Bubble.create(i,j);
        **bubble.setState(BubbleShoot.BubbleState.ON_BOARD);**
        row[j] = bubble;
      };
      rows.push(row);
    };
    return rows;
  };
  return Board;
})(jQuery);

bubble.setState处理设置,其中包括CURRENTON_BOARD的状态,但我们还需要能够改变气泡的状态。

FIRINGFIRED这两个状态将在ui.js中的fireBubble内设置。请按如下方式修改该函数:

ui.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.ui = (function($){
    var ui = {
      --*snip*--
      fireBubble : function(bubble,coords,duration){
➊      **bubble.setState(BubbleShoot.BubbleState.FIRING);**
        var complete = function(){
          if(typeof(bubble.getRow()) !== undefined){
            bubble.getSprite().css(Modernizr.prefixed("transition"),"");
            bubble.getSprite().css({
              left : bubble.getCoords().left - ui.BUBBLE_DIMS/2,
              top : bubble.getCoords().top - ui.BUBBLE_DIMS/2
            });
➋          **bubble.setState(BubbleShoot.BubbleState.ON_BOARD);**
          **}else{**
➌          **bubble.setState(BubbleShoot.BubbleState.FIRED);**
          **};**
        --*snip*--
      },
      --*snip*--
    };
    return ui;
  } )(jQuery);

当气泡最初被发射时,我们将其状态设置为FIRING ➊。如果气泡到达棋盘,我们将其状态设置为ON_BOARD ➋,但如果它还没有稳定在某一行和列上,那么它就错过了棋盘,此时它的状态变为FIRED ➌。

其他状态将在game.js中设置:

game.js

  var Game = function(){
    --*snip*--
    var popBubbles = function(bubbles,delay){
      $.each(bubbles,function(){
        var bubble = this;
        setTimeout(function(){
➊        **bubble.setState(BubbleShoot.BubbleState.POPPING);**
          bubble.animatePop();
➋        **setTimeout(function(){**
            **bubble.setState(BubbleShoot.BubbleState.POPPED);**
          **},200);**
        },delay);
        board.popBubbleAt(bubble.getRow(),bubble.getCol());
        delay += 60;
      });
    };
    var dropBubbles = function(bubbles,delay){
      $.each(bubbles,function(){
        var bubble = this;
        board.popBubbleAt(bubble.getRow(),bubble.getCol());
        setTimeout(function(){
➌        **bubble.setState(BubbleShoot.BubbleState.FALLING);**
          bubble.getSprite().kaboom({
            callback : function(){
              bubble.getSprite().remove();
➍            **bubble.setState(BubbleShoot.BubbleState.FALLEN);**
            }
          })
        },delay);
      });
    };
  };

popBubbles中,我们将每个气泡的状态设置为POPPING ➊,然后在 200 毫秒后,当爆裂动画完成时,我们将其状态设置为POPPED ➋。在dropBubbles中,我们将其状态设置为FALLING ➌,然后在kaboom过程结束时,当它们完成下落后,它们变为FALLEN ➍。

现在,气泡知道它们在游戏中的任何时刻处于哪种状态,我们可以开始将它们渲染到画布上。

精灵图和画布

我们可以使用游戏的 CSS 版本中现有的精灵图 PNG (bubble_sprite_sheet.png) 来绘制 canvas,尽管我们需要以不同的方式处理它。我们不会像处理背景图像那样移动精灵图,而是绘制图像的一部分,显示正确的气泡及其正确的动画状态。我们的加载顺序也会改变,因为我们需要确保精灵图在游戏开始前已被加载。

我们将创建一个新的对象Renderer来处理渲染到 canvas 的操作,并为它定义一个init方法,该方法将预加载精灵图,并在game.init中调用该方法。

game.js中的init方法更改为如下内容:

game.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
    var Game = function(){
      --*snip*--
      this.init = function(){
➊    **if(BubbleShoot.Renderer){**
➋      **BubbleShoot.Renderer.init(function(){**
➌          **$(".but_start_game").click("click",startGame);**
        **});**
      **}else{**
          $(".but_start_game").click("click",startGame);
      **};**
      --*snip*--
    };
    return Game;
  })(jQuery);

首先,我们检查BubbleShoot.Renderer是否存在 ➊。 如果加载脚本时Modernizr.canvas测试通过,那么该对象将存在;如果不支持 canvas,该对象则不存在。

然后,我们调用Renderer.init方法,并将一个函数作为唯一参数传入 ➋。这个函数将startGame附加到“新游戏”按钮 ➌。

现在我们需要编写Renderer对象。在空白的renderer.js文件中,添加以下代码:

renderer.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Renderer = (function($){
➊  var canvas;
    var context;
    var Renderer = {
➋    init : function(callback){
➌      canvas = document.createElement("canvas");
        $(canvas).addClass("game_canvas");
➍      $("#game").prepend(canvas);
➎      $(canvas).attr("width",$(canvas).width());
        $(canvas).attr("height",$(canvas).height());
        context = canvas.getContext("2d");
        callback();
      }
    };
    return Renderer;
  })(jQuery);

我们首先创建变量来保存用于渲染游戏区域的 canvas ➊,以及它的渲染上下文引用,这样我们就不需要不断调用canvas.getContext("2d")

init方法中,我们将回调函数作为参数 ➋,创建canvas DOM 元素 ➌,然后将其添加到游戏的div中 ➍。我们还显式地设置canvas的宽度和高度属性 ➎。请记住,这些属性定义了 canvas 内部的像素数量和边界,因此为了简化起见,我们将它们设置为与渲染到屏幕上的尺寸相同。

这将为我们创建canvas元素,并准备好一个可以绘制的上下文。我们需要设置game_canvas的宽度和高度,因此请将以下内容添加到main.css中:

main.css

.game_canvas
{
  width: 1000px;
  height: 620px;
}

DOM 渲染版本使用 jQuery 来在屏幕上移动对象,但在 canvas 内我们没有 DOM 元素可以操作,因此没有任何内容可以供 jQuery 处理。因此,我们必须通过新的代码来跟踪每个气泡在屏幕上的位置。大部分工作将在我们创建的新sprite.js文件中完成。

多种渲染方法:两种方案

如果你需要支持不同的渲染方法,就像我们这里所做的,你可以采用两种方案。首先,你可以为每种渲染方法创建一个类,并提供相同的方法和属性集,以便它们可以互换使用。这正是我们在Bubble Shooter中所做的。

第二,你可以为两种渲染方法创建一个类,然后在该类内部根据支持的渲染方法编写分支代码。这个新类可能只是为每种方法包装不同的类。例如,对于Bubble Shooter,我们可以创建如下的伪代码:

  BubbleShoot.SpriteWrapper = (function($){
➊  var SpriteWrapper = function(id){
      var wrappedObject;
➋    if(BubbleShoot.Renderer){
➌      wrappedObject = getSpriteObject(id);
      }else{
➍      wrappedObject = getJQueryObject(id);
      }
➎    this.position = function(){
        return wrappedObject.position();
      };
    };
    return SpriteWrapper;
  })(jQuery);

在这里,我们会将某种标识符传递给对象构造函数 ➊,然后根据我们将如何渲染游戏来分支代码 ➋。我们将需要新的函数来返回一个 Sprite ➌ 或一个 jQuery ➍ 对象,这些对象将存储在类中的 wrappedObject 内。

从那时起,如果我们想要查找对象的位置,我们会调用 position 方法 ➎,并且可以知道无论对象是在 DOM 中还是在画布上渲染,都会得到正确的数据。

我们不采用这种方法的主要原因是,Bubble Shooter 中只有一种类型的精灵——屏幕上的气泡。这些精灵由 Bubble 类很好地表示,它本身就充当了一个包装器。然而,如果我们处理的是多种不同类型的精灵,我们可能会希望更明确地拆分结构。

我们将编写 sprite.js,使得画布精灵可以使用与 jQuery 精灵相同的方法进行调用。我们一直在调用的主要方法有 positionwidthheightcss,如果我们在 sprite.js 中创建这些方法的实现,Sprite 类看起来就像一个 jQuery 对象,对我们其余代码的影响是一样的。

将以下内容添加到 sprite.js 中:

sprite.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Sprite = (function($){
    var Sprite = function(){
      var that = this;
➊    var left;
      var top;
➋    this.position = function(){
        return {
          left : left,
          top : top
        };
      };
➌    this.setPosition = function(args){
        if(arguments.length > 1){
          return;
        };
        if(args.left !== null)
          left = args.left;
        if(args.top !== null)
          top = args.top;
      };
➍    this.css = this.setPosition;
      return this;
    };
➎  Sprite.prototype.width = function(){
      return BubbleShoot.ui.BUBBLE_DIMS;
    };
➏  Sprite.prototype.height = function(){
      return BubbleShoot.ui.BUBBLE_DIMS;
    };
➐  Sprite.prototype.removeClass = function(){};
    Sprite.prototype.addClass = function(){};
    Sprite.prototype.remove = function(){};
    Sprite.prototype.kaboom = function(){
      jQuery.fn.kaboom.apply(this);
    };
    return Sprite;
  })(jQuery);

在这里,我们创建了一个对象,实现了我们访问 jQuery 对象时使用的许多方法。我们有左坐标和上坐标 ➊ 以及一个返回这些坐标的 position 方法 ➋,这个方法的返回方式与调用 jQuery 的 position 方法相同。setPosition 方法可以设置上坐标和左坐标 ➌,如果传入其他值则不做任何操作。

在我们基于 DOM 的游戏版本中,我们调用 css 方法来设置对象的屏幕坐标。setPosition 已经构建成接受与 css 方法相同的参数,为了避免在调用 css 方法的地方重写代码,并且在画布版本中使用 setPosition,我们可以为 Sprite 创建一个 css 方法并将其别名为 setPosition ➍。

width ➎ 和 height ➏ 方法返回在 ui.js 中为气泡的尺寸定义的值。最后,我们为 removeClassaddClassremove 定义了空方法,这些方法与我们现有的很多代码保持兼容性 ➐。调用这些方法的地方不会影响显示,但也不会抛出错误。

当一个气泡被创建时,我们需要决定是创建一个 jQuery 对象还是一个 Sprite 实例,这取决于我们是使用 DOM 还是画布进行渲染。我们将在 bubble.js 中的气泡创建过程中执行此操作:

bubble.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Bubble = (function($){
    --*snip*--
    var Bubble = function(row,col,type,sprite){
      --*snip*--
    };
    Bubble.create = function(rowNum,colNum,type){
      if(!type){
        type = Math.floor(Math.random() * 4);
      };
➊    **if(!BubbleShoot.Renderer){**
        var sprite = $(document.createElement("div"));
        sprite.addClass("bubble");
        sprite.addClass("bubble_" + type);
      **}else{**
➋      **var sprite = new BubbleShoot.Sprite();**
      **}**
      var bubble = new Bubble(rowNum,colNum,type,sprite);
      return bubble;
    };
    return Bubble;
  })(jQuery);

这段代码再次检查 Renderer 对象是否已加载 ➊(如果启用了画布的话会发生这种情况),如果没有,它将继续执行基于 DOM 的路径。否则,我们会创建一个新的 Sprite 对象 ➋。有了这个,调用 curBubble.getSprite 时,无论我们是使用 jQuery 和 CSS 还是纯画布方式,都能返回一个有效的对象。

初始化Sprite对象的最后一步是确保它们具有正确的屏幕坐标。在游戏的 DOM 版本中,我们通过 CSS 设置这些坐标,但在画布中,我们必须通过 JavaScript 代码来设置这些坐标。这些坐标将会在board.js中的createLayout函数中设置:

board.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Board = (function($){
    var NUM_ROWS = 9;
    var NUM_COLS = 32;
    var Board = function(){
      --*snip*--
      return this;
    };
    var createLayout = function(){
      var rows = [];
      for(var i=0;i<NUM_ROWS;i++){
        var row = [];
        var startCol = i%2 == 0 ? 1 : 0;
        for(var j=startCol;j<NUM_COLS;j+=2){
          var bubble = BubbleShoot.Bubble.create(i,j);
          bubble.setState(BubbleShoot.BubbleState.ON_BOARD);
➊        **if(BubbleShoot.Renderer){**
➋          **var left = j * BubbleShoot.ui.BUBBLE_DIMS/2;**
            **var top = i * BubbleShoot.ui.ROW_HEIGHT;**
➌          **bubble.getSprite().setPosition({**
              **left : left,**
              **top : top**
            **});**
          **};**
          row[j] = bubble;
        };
        rows.push(row);
      };
      return rows;
    };
    return Board;
  })(jQuery);

如果渲染器存在 ➊,我们计算气泡应显示的左上角坐标 ➋,然后将精灵的属性设置为这些值 ➌。

当前气泡也需要设置其位置,因此这将在game.js中的getNextBubble函数内进行:

game.js

var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
  var Game = function(){
    --*snip*--
    var getNextBubble = function(){
      var bubble = BubbleShoot.Bubble.create();
      bubbles.push(bubble);
      bubble.setState(BubbleShoot.BubbleState.CURRENT);
      bubble.getSprite().addClass("cur_bubble");
      **var top = 470;**
      **var left = ($("#board").width() - BubbleShoot.ui.BUBBLE_DIMS)/2;**
      **bubble.getSprite().css({**
        **top : top,**
        **left : left**
      **});**
      $("#board").append(bubble.getSprite());
      BubbleShoot.ui.drawBubblesRemaining(numBubbles);
      numBubbles--;
      return bubble;
    };
    --*snip*-
  };
  return Game;
})(jQuery);

现在我们已经追踪了所有气泡的位置,并且随时了解它们的状态。我们还可以操作精灵表示,但目前屏幕上什么都不会出现。在下一部分,我们将把精灵渲染到画布上。

画布渲染器

要在画布上动画化任何东西,我们需要在每次重新绘制之前清除像素。为了渲染游戏,我们将使用setTimeout和计时器,以逐帧的方式重新绘制每个气泡的位置和状态。这个过程几乎适用于任何你构建的游戏,尤其是那些需要不断更新显示的游戏。从理论上讲,我们只需在屏幕上的信息发生变化时重新绘制画布;但实际上,弄清楚何时有新信息需要显示可能会很困难。幸运的是,画布渲染速度非常快,所以通常没有理由不尽可能频繁地更新显示。

我们将存储setTimeout返回的超时 ID 值,这样就能知道帧计数器是否正在运行。这将在game.js的顶部通过一个名为requestAnimationID的新变量进行存储,同时我们还会存储上次动画发生的时间戳:

game.js

var BubbleShoot = window.BubbleShoot || {};
    var Game = function(){
      var curBubble;
      var board;
      var numBubbles;
      var bubbles = [];
      var MAX_BUBBLES = 70;
➊    **var requestAnimationID;**
      this.init = function(){
      };
      --*snip*--
        var startGame = function(){
        $(".but_start_game").unbind("click");
        $("#board .bubble").remove();
        numBubbles = MAX_BUBBLES;
        BubbleShoot.ui.hideDialog();
        board = new BubbleShoot.Board();
        bubbles = board.getBubbles();
➋      **if(BubbleShoot.Renderer)**
        **{**
          **if(!requestAnimationID)**
➌          **requestAnimationID = setTimeout(renderFrame,40);**
        **}else{**
          BubbleShoot.ui.drawBoard(board);
        **};**
        curBubble = getNextBubble(board);
        $("#game").bind("click",clickGameScreen);
      };
    };
    return Game;
  })(jQuery);

我们添加了两个变量 ➊,如果Renderer对象存在 ➋,我们开始启动超时计时器,绘制第一个动画帧 ➌。

我们还没有编写renderFrame,但在此之前,我们将在renderer.js中编写一个方法来绘制所有气泡。这个方法将接受一个bubble对象的数组作为输入。

首先,我们需要将气泡图像加载到renderer.js中:

renderer.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Renderer = (function($){ var canvas;
    var context;
➊  **var spriteSheet;**
➋  **var BUBBLE_IMAGE_DIM = 50;**
    var Renderer = {
      init : function(callback){
        canvas = document.createElement("canvas");
        $(canvas).addClass("game_canvas");
        $("#game").prepend(canvas);
        $(canvas).attr("width",$(canvas).width());
        $(canvas).attr("height",$(canvas).height());
        context = canvas.getContext("2d");
        **spriteSheet = new Image();**
➌      **spriteSheet.src = "_img/bubble_sprite_sheet.png";**
➍      **spriteSheet.onload = function() {**
          callback();
        **};**
      }
    };
    return Renderer;
  })(jQuery);

我们创建一个变量来存储图像数据 ➊,并定义另一个变量来表示每个气泡图像的宽度和高度 ➋。这些尺寸将告诉我们如何在精灵图集中裁剪每张图像。然后我们加载图像文件 ➌,在图像加载完成后传入init的回调函数会被触发 ➍。

接下来,我们将创建一个函数,将精灵绘制到画布上。

renderer.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Renderer = (function($){
    --*snip*--
    var Renderer = {
      init : function(callback){
        --*snip*--
      },
➊    **render : function(bubbles){**
        **context.clearRect(0,0,canvas.width,canvas.height);**
        **context.translate(120,0);**
➋      **$.each(bubbles,function(){**
          **var bubble = this;**
➌        **var clip = {**
            **top : bubble.getType() * BUBBLE_IMAGE_DIM,**
            **left : 0**
          **};**
➍        **Renderer.drawSprite(bubble.getSprite(),clip);**
        **});**
        **context.translate(-120,0);**
      **},**
      **drawSprite : function(sprite,clip){**
➎      **context.translate(sprite.position().left + sprite.width()/2,sprite.**
          **position().top + sprite.height()/2);**
➏      **context.drawImage(spriteSheet,clip.left,clip.top,BUBBLE_IMAGE_DIM,**
          **BUBBLE_IMAGE_DIM,-sprite.width()/2,-sprite.height()/2,BUBBLE_IMAGE_**
          **DIM,BUBBLE_IMAGE_DIM);**
➐      **context.translate(-sprite.position().left - sprite.width()/2,**
          **-sprite.position().top - sprite.height()/2);**
      **}**
    };
    return Renderer;
  })(jQuery);

首先,我们创建一个渲染方法,它接受一个 Bubble 对象的数组 ➊。然后,我们清除画布并将上下文偏移 120 像素,以便棋盘显示在屏幕的中央。接着,代码会循环遍历数组中的每个气泡 ➋,并定义一个 (x,y) 坐标,从中提取气泡的精灵图像 ➌。x 坐标始终从零开始,直到我们为弹出动画添加帧,而 y 坐标则是气泡类型(0 到 3)乘以气泡图像的高度(50 像素)。我们将这些信息以及气泡的 Sprite 对象传递给另一个新方法 drawSprite ➍,然后重置上下文位置。

drawSprite 中,我们通过精灵的坐标 ➎ 来平移上下文,并记得将(top,left)坐标偏移图像的一半(width,height),以便将图像的中心放在原点上,然后绘制图像 ➏。一般来说,最好将画布上下文平移,使其原点位于任何绘制图像的中心,因为上下文的 rotate 方法是围绕上下文的原点进行旋转的。这意味着,如果我们想围绕图像的中心旋转图像,那么上下文已经设置好了,能够正确地执行旋转。

最后,在调用 drawImage 后,我们将上下文平移回原点 ➐。为了看到棋盘被渲染到画布上,我们只需要在 game.js 中加入 renderFrame

game.js

var BubbleShoot = window.BubbleShoot || {};
  var Game = function(){
    --*snip*--
    **var renderFrame = function(){**
      **BubbleShoot.Renderer.render(bubbles);**
      **requestAnimationID = setTimeout(renderFrame,40);**
    **};**
  };
  return Game;
})(jQuery);

在浏览器中重新加载页面以重新开始游戏。点击“新游戏”后,你应该会看到棋盘在初始状态下渲染出来。然而,发射气泡时没有动画效果,弹出、下落或其他任何动作也都没有动画效果。在下一部分,我们将重新实现气泡发射的动画,并且为气泡弹出的动画添加动画效果。如果你在不支持画布的浏览器中打开游戏,游戏仍然会像以前一样运行,因为我们保留了 DOM 版本。接下来,我们将为画布版本添加动画效果。

在画布上移动精灵

在游戏的 CSS 版本中,我们使用 jQuery 通过调用 animate 方法来移动屏幕上的物体。对于画布动画,我们需要手动计算并更新物体的移动。

在画布上动画的过程与 jQuery 内部的过程相同,我们将给 Sprite 添加一个 animate 方法,以便继续使用现有的代码。animate 方法将执行以下操作:

  1. 接受气泡的目标坐标和移动的持续时间。

  2. 根据自上次帧以来经过的时间按比例将物体移动到目标坐标的一个小距离。

  3. 重复步骤 2,直到气泡到达目标位置。

这个过程与我们使用 jQuery 的 animate 方法时的过程完全相同,并且是你每次想要移动屏幕上的物体时都会使用的方法。

renderFrame 方法已经在每一帧时被调用,它将运行整个动画过程。气泡精灵计算出自己的坐标后,renderFrame 将触发绘制过程。我们会在 Sprite 对象中添加一个 animate 方法,这样我们现有的游戏逻辑就可以正常工作,而无需重写代码。记住,当我们在 ui.js 中调用 animate 时,我们传入了两个参数:

  • 一个指定 lefttop 位置坐标的对象

  • 一个指定 durationcallback 函数和 easing 的对象

通过构建 Spriteanimate 方法以接收相同的参数,我们可以避免对 ui.js 中的调用做出任何更改。将以下内容添加到 sprite.js

sprite.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Sprite = (function($){
    var Sprite = function(){
      --*snip*--
      this.css = function(args){
        --*snip*--
      };
➊    **this.animate = function(destination,config){**
➋      **var duration = config.duration;**
➌      **var animationStart = Date.now();**
➍      **var startPosition = that.position();**
➎      **that.updateFrame = function(){**
          **var elapsed = Date.now() - animationStart;**
          **var proportion = elapsed/duration;**
          **if(proportion > 1)**
            **proportion = 1;**
➏        **var posLeft = startPosition.left + (destination.left - startPosition.**
            **left) * proportion;**
          **var posTop = startPosition.top + (destination.top - startPosition.top)**
            *** proportion;**
➐        **that.css({**
            **left : posLeft,**
            **top : posTop**
          **});**
        **};**
➑      **setTimeout(function(){**
➒        **that.updateFrame = null;**
➓        **if(config.complete)**
            **config.complete();**
        **},duration);**
      **};**
      return this;
    };
    --*snip*--
    return Sprite;
  })(jQuery);

传递给 animatedestination 参数 ➊ 代表精灵的目标坐标,这些坐标包含在一个看起来像这样的对象中:

{top: 100,left: 100}

我们还传递一个配置对象,该对象将具有 duration 属性 ➋,并且可以有一个可选的后动画回调函数,当动画结束时运行。

接下来,我们为动画设置一个开始时间 ➌,并存储起始位置 ➍。这两个值将用于随时计算气泡的位置。

我们动态地将 updateFrame 方法添加到 Sprite 对象上 ➎,这样我们就可以在每一帧调用它重新计算气泡的位置。在 updateFrame 内部,我们计算动画的完成度。如果最后一个超时调用发生在动画完成之后,我们确保比例永远不会大于 1,从而避免气泡越过目标位置。新坐标的计算 ➏ 使用以下公式:

  • 当前 x = 起始 x + (最终 x – 起始 x) × 经过的比例

  • 当前的 y = 起始 y + (最终 y – 起始 y) × 经过的比例

一旦我们得到了新的顶部和左侧坐标,精灵的位置会通过调用它的 css 方法 ➐ 来更新。我们不需要在对象移动完成后继续运行 updateFrame,因此设置了一个超时调用 ➑ 来在 duration ➒ 过去时移除该方法,此时动画将完成。这也会调用任何作为 config 变量的 callback 属性传入的后动画函数 ➓。

现在我们可以计算气泡的新坐标,接下来在 game.js 中添加一个对updateFrame的调用:

game.js

  var BubbleShoot = window.BubbleShoot || {};
    var Game = function(){
      --*snip*--
      var renderFrame = function(){
➊      **$.each(bubbles,function(){**
➋        **if(this.getSprite().updateFrame)**
➌          **this.getSprite().updateFrame();**
        **});**
        BubbleShoot.Renderer.render(bubbles);
        requestAnimationID = setTimeout(renderFrame,40);
      };
    };
    return Game;
  })(jQuery);

每次在气泡上调用 renderFrame ➊ 时,如果定义了 updateFrame 方法 ➋,我们就调用该方法 ➌。

我们还需要在 fireBubble 中调用 animate,在 ui.js 里再次检查 BubbleShoot.Renderer 是否存在。我们知道只有在支持 canvas 的情况下,BubbleShoot.Renderer 才会存在,我们希望在这种情况下使用 canvas 渲染。最终的结果是,只有在支持 CSS 过渡动画并且不支持 canvas 渲染时,CSS 过渡动画才会对气泡进行动画处理。

ui.js

var BubbleShoot = window.BubbleShoot || {};
BubbleShoot.ui = (function($){
  var ui = {
    --*snip*--
    fireBubble : function(bubble,coords,duration){
      --*snip*--
      if(Modernizr.csstransitions **&& !BubbleShoot.Renderer**){
        --*snip*--
      }else{
        --*snip*--
      }
    },
    --*snip*--
  };
  return ui;
} )(jQuery);

重新加载游戏并开始射击!现在你应该能再次玩游戏了,但这次所有图像都会被渲染到画布上。不过现在没有爆炸动画,因为我们没有在显示中处理气泡状态的变化。游戏状态在内部是正确的,但屏幕显示不完全同步,因为我们从未看到气泡爆炸。将气泡渲染为正确的状态是下一节的重点。

动画画布精灵帧

目前,所有气泡都渲染为相同的视觉状态,无论它们是处于棋盘上、爆炸中、刚被发射,等等。气泡在爆炸后仍会留在屏幕上,而且我们错过了爆炸动画!这是因为气泡从未从Game中的bubbles数组中删除,因此即使它们已经从Board对象中删除,仍然会被渲染。

我们已经知道气泡处于哪个状态,并且已经将精灵图加载到内存中,以便访问所有动画状态。绘制正确的状态需要确保RendererdrawSprite方法要么以正确的状态调用以显示气泡,要么完全跳过任何已经爆炸或从屏幕上消失的气泡。我们需要实现的气泡外观变化按状态列出在表 6-1 中。

表 6-1. 基于气泡状态的视觉变化

代码中的气泡状态 显示给玩家的视觉效果
CURRENT_BUBBLE 无变化
ON_BOARD 无变化
FIRING 无变化
POPPING 根据气泡已经处于POPPING状态的时间渲染四个气泡帧中的一个
FALLING 无变化
POPPED 跳过渲染
FALLEN 跳过渲染
FIRED 跳过渲染

这些更改将在Renderer.render内部进行。我们将遍历整个气泡数组,并根据情况跳过渲染阶段或调整坐标,以便为正确的爆炸动画阶段裁剪精灵图。请对renderer.js进行以下更改:

renderer.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Renderer = (function($){
    --*snip*--
    var Renderer = {
      init : function(callback){
        --*snip*--
      },
      render : function(bubbles){
        bubbles.each(function(){
          var bubble = this;
          var clip = {
            top : bubble.getType() * BUBBLE_IMAGE_DIM,
            left : 0
          };
➊        **switch(bubble.getState()){**
            **case BubbleShoot.BubbleState.POPPING:**
➋            **var timeInState = bubble.getTimeInState();**
➌            **if(timeInState < 80){**
                **clip.left = BUBBLE_IMAGE_DIM;**
➍            **}else if(timeInState < 140){**
                **clip.left = BUBBLE_IMAGE_DIM*2;**
➎            **}else{**
                **clip.left = BUBBLE_IMAGE_DIM*3;**
              **};**
              **break;**
➏          **case BubbleShoot.BubbleState.POPPED:**
              **return;**
➐          **case BubbleShoot.BubbleState.FIRED:**
              **return;**
➑          **case BubbleShoot.BubbleState.FALLEN:**
              **return;**
          **}**
  ➒        Renderer.drawSprite(bubble.getSprite(),clip);
        });
      },
      drawSprite : function(sprite,clip){
        --*snip*--
      }
    };
    return Renderer;
  })(jQuery);

首先,我们需要查看气泡处于哪种状态➊。为此,我们将使用switch语句。状态机通常使用switch/case语句编写,而不是多个if/else语句。使用这种结构不仅使得将来添加任何新状态变得更加容易,而且还为将来阅读代码的其他人提供了一个线索,告诉他们正在查看的是一个状态机。

如果气泡正在爆炸,我们需要知道它已经处于该状态多长时间➋。这个时间决定了我们要获取哪个动画帧。前 80 毫秒使用未爆炸状态➌,接下来的 60 毫秒使用第一帧➍,从那时起直到POPPING状态被清除之前使用最后的爆炸帧➎。

如果气泡处于POPPED ➏、FIRED ➐ 或 FALLEN ➑ 状态,我们会返回并跳过渲染。否则,我们像以前一样调用 drawSprite ➒。

现在,如果你重新加载游戏,它应该会完全正常工作。我们没有做剧烈的修改,只是重构了整个游戏区域,根据浏览器的兼容性使用 canvas 或 DOM 渲染。你用来加载游戏的浏览器以及该浏览器支持的功能将决定Bubble Shooter如何呈现给你:

  • 如果你的浏览器支持canvas元素,你将看到该版本。

  • 如果你的浏览器支持 CSS 过渡效果,但支持canvas元素,你将看到 CSS 过渡效果版本。

  • 如果上述两者都不支持,你将看到使用 jQuery 动画的 DOM 版本。

总结

这涵盖了绘制 HTML5 游戏图形元素的大部分核心内容,无论你是使用 HTML 和 CSS,还是完全基于 canvas 的方法。但这并不意味着我们的游戏完成了!我们没有声音,只有一个关卡,另外一个得分系统会更好。接下来的章节中,我们将实现这些元素,并探索更多 HTML5 的功能,包括用于保存游戏状态的本地存储,requestAnimationFrame 以实现更流畅的动画,以及如何让声音可靠地工作。

进一步练习

  1. 当气泡爆炸时,每个气泡的动画播放是相同的。试着改变时间,使一些气泡播放动画更快,另一些更慢。同时,尝试为气泡添加一些旋转效果,当它们被绘制到 canvas 上时。这应该会让爆炸动画看起来更丰富,而且几乎不需要太多努力。

  2. 当孤立的气泡下降时,它们仍然保持为默认的精灵。修改renderer.js,使得气泡在下落时会爆炸。

第七章. 关卡、声音与更多

在这一章中,我们将为Bubble Shooter添加一些收尾工作,并讲解 HTML5 的更多功能。现在,气泡网格可能会迅速填满整个页面,导致玩家没有空间发射气泡。为了防止这种情况发生,我们将设置当玩家向棋盘底部添加超过两行时,游戏结束。我们还将使用本地存储 API 实现多个关卡和高分,利用requestAnimationFrame平滑动画,并通过 HTML5 为游戏添加声音。让我们从添加多个关卡和高分开始。

多个关卡和高分

完成一个关卡后,理论上可以通过清除所有气泡来完成,但如果你想重新开始游戏,则必须刷新浏览器。显然,这对于一款游戏来说是不令人满意的,而且还缺少一些其他的游戏流程元素:

  • 有限的气泡供应(否则,玩家可以无限发射,导致气泡计数器显示负数!)

  • 一个计分系统

  • 关卡结束条件

  • 后续关卡

游戏将为每个爆破的气泡奖励积分,这些积分将累计到玩家的分数中。我们已经拥有限制玩家气泡供应所需的信息,因为我们在计数气泡,尽管我们的计数可能会变成负数。为了添加多个逐渐增加难度的关卡,我们将在每个关卡给予玩家更少的气泡。

新的游戏状态变量

我们需要采取的第一步是整合气泡计数器并创建其他游戏状态变量。我们可以创建一个新对象来存储所有的游戏状态参数,例如玩家的分数、剩余气泡数、关卡编号等。或者,我们可以将这些作为变量存储在Game对象内部。我选择了后者,因为我们只需要追踪三个值。如果你需要追踪更多信息,或者追踪的信息更复杂,最好将数据存储在单独的对象中,以保持game.js尽可能简洁和易读。

让我们在Game类的顶部添加一些新变量,并根据关卡编号为玩家提供不同数量的气泡来完成关卡:

game.js

  var BubbleShoot = window.BubbleShoot || {};
    BubbleShoot.Game = (function($){
    var Game = function(){
      var curBubble;
      var board;
      var numBubbles;
      var bubbles = [];
      var MAX_BUBBLES = 70;
➊    **var POINTS_PER_BUBBLE = 50;**
➋    **var level = 0;**
➌    **var score = 0;**
➍    **var highScore = 0;**
      var requestAnimationID;
      this.init = function(){
        --*snip*--
      };
      var startGame = function(){
        $(".but_start_game").unbind("click");
        BubbleShoot.ui.hideDialog();
➎      numBubbles = MAX_BUBBLES **- level * 5;**
        board = new BubbleShoot.Board();
        bubbles = board.getBubbles();
        if(BubbleShoot.Renderer)
        {
          if(!requestAnimationID)
            requestAnimationID = setTimeout(renderFrame,40);
        }else{
          BubbleShoot.ui.drawBoard(board);
        };
        curBubble = getNextBubble();
        $("#game").bind("click",clickGameScreen);
      };
      --*snip*--
    };
    return Game;
  })(jQuery);

我们已经创建了新的变量来记录每个气泡的奖励积分 ➊、玩家当前的关卡 ➋、他们当前的分数 ➌,以及一个高分 ➍。当游戏开始时,我们会根据玩家已完成的关卡减少 5 个气泡 ➎。在第一个关卡,玩家将获得 70 个气泡,在第二个关卡,他们有 65 个,以此类推。

注意

你可能会注意到我们在计算可用气泡数量时存在一些问题。首先,完成第 14 关是不可能的,因为此时用户将得到零个气泡。其次,之前的关卡也会变得非常困难。很难想象只用 20 或 30 个气泡就能完成一个关卡,更别提只有 10 或 15 个了!我将把这个问题的解决方案留给章节最后作为练习。

显示关卡和分数

我们还没有地方来显示分数,所以我们将在 index.html 中添加一个 DOM 元素来显示分数,以及一个显示当前关卡和最高分的地方。屏幕顶部的条形区域是一个很好的布局位置来显示这些信息。新元素如图 Figure 7-1 所示。

显示关卡、分数和最高分的屏幕布局

图 7-1. 显示关卡、分数和最高分的屏幕布局

index.html

  <!DOCTYPE HTML>
  <html lang="en-US">
  <head>
    --*snip*--
  </head>
  <body>
  <div id="page">
    <div id="top_bar">
➊    **<div id="top_level_box" class="top_bar_box">**
        **<div id="top_level_label">Level:</div>**
        **<div id="level">1</div>**
      **</div>**
➋    **<div class="top_bar_box">**
        **<div id="top_score_label">Score:</div>**
        **<div id="score">0</div>**
      **</div>**
➌    **<div class="top_bar_box">**
        **<div id="top_score_label">High Score:</div>**
        **<div id="high_score">0</div>**
      **</div>**
    </div>
    --*snip*--
  </div>
  </body>
  </html>

添加了三个新的 <div> 元素:分别用于显示关卡数字 ➊、当前游戏分数 ➋ 和最高分 ➌。每个 <div> 都有一个元素来显示标签,然后是一个值。

这些也需要在 main.css 中定义样式:

main.css

  body
  {
    margin: 0;
  }
  #page
  {
    position: absolute;
    left: 0;
    top: 0;
    width: 1000px;
    height: 738px;
  }
    #top_bar
    {
      position: absolute;
      left: 0;
      top: 0;
      width: 1000px;
      height: 70px;
      background-color: #369;
      color: #fff;
    }
➊    **.top_bar_box**
      **{**
        **font-size: 24px;**
        **line-height: 60px;**
        **float: left;**
        **margin-left:20px;**
        **width: 250px;**
      **}**
➋      **.top_bar_box div**
        **{**
          **float: left;**
          **margin-right: 20px;**
        **}**
  --*snip*--

我没有单独为这三个元素分别设置样式;相反,我给它们分配了一个共同的类 top_bar_box ➊。基础的 CSS 样式为每个元素设置了 250 像素的宽度,并将其浮动到左侧,因此这些元素会在 top_bar 内部排列成一行,位于屏幕顶部。每个元素显示的标签和值都放在一个 <div> 中,因此其样式直接应用在这个 <div> 上,而不需要创建新的 CSS 类 ➋。

现在让我们为玩家奖励一些分数,并显示他们的分数和关卡。当气泡被戳破或孤立时,需要奖励并显示分数,游戏开始时也应该显示分数和关卡值。首先,我们需要在 ui.js 中编写函数,将这些值绘制到屏幕上。我们将它们放在 ui.js 中,以便继续保持 game.js 不包含显示代码:

ui.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.ui = (function($){
    --*snip*--
    var ui = {
      --*snip*--
➊    **drawScore : function(score){**
        **$("#score").text(score);**
      **},**
➋    **drawHighScore : function(highScore){**
        **$("#high_score").text(highScore);**
      **},**
➌    **drawLevel : function(level){**
        **$("#level").text(level+1);**
      **}**
    };
    --*snip*--
    return ui;
  } )(jQuery);

drawScore ➊ 和 drawHighScore ➋ 接受分数值并将其绘制到屏幕上相关的 <div> 中。drawLevel 写入关卡数字,但首先会加 1,因为内部关卡状态从零开始 ➌。虽然这三个函数都只有一行代码,但最好为它们创建独立的函数,并且像这样编写 ui.drawScore(score),而不是每次更新分数时都写 $("#score").text(score)。然后,如果你想在元素变化时添加视觉效果,可以只在一个函数中进行修改,而不必追踪每个更新分数的地方。如果你希望分数在每次增加时闪烁,那么你只需要在一个地方进行修改。

现在我们将这些函数调用添加到 game.jsstartGameclickScreen 中:

game.js

  var BubbleShoot = window.BubbleShoot || {};
    BubbleShoot.Game = (function($){
    var Game = function(){
      --*snip*--
      var startGame = function(){
        $(".but_start_game").unbind("click");
        BubbleShoot.ui.hideDialog();
        numBubbles = MAX_BUBBLES;
        board = new BubbleShoot.Board();
        bubbles = board.getBubbles();
        if(BubbleShoot.Renderer)
        {
          if(!requestAnimationID)
            requestAnimationID = setTimeout(renderFrame,40);
        }else{
          BubbleShoot.ui.drawBoard(board);
        };
        curBubble = getNextBubble();
        $("#game").bind("click",clickGameScreen);
➊      **BubbleShoot.ui.drawScore(score);**
        **BubbleShoot.ui.drawLevel(level);**
      };
      var clickGameScreen = function(e){
        var angle = BubbleShoot.ui.getBubbleAngle(curBubble.getSprite(),e,board.
          calculateTop());
        var duration = 750;
        var distance = 1000;
        var collision = BubbleShoot.CollisionDetector.findIntersection(curBubble,
          board,angle);
        if(collision){
          var coords = collision.coords;
          duration = Math.round(duration * collision.distToCollision / distance);
          board.addBubble(curBubble,coords);
          var group = board.getGroup(curBubble,{});
          if(group.list.length >= 3){
            popBubbles(group.list,duration);
            var orphans = board.findOrphans();
            var delay = duration + 200 + 30 * group.list.length;
            dropBubbles(orphans,delay);
➋          **var popped = [].concat(group.list,orphans);**
➌          **var points = popped.length * POINTS_PER_BUBBLE;**
➍          **score += points;**
➎          **setTimeout(function(){**
              **BubbleShoot.ui.drawScore(score);**
            **},delay);**
          };
        }else{
          --*snip*--
        };
        --*snip*--
      };
      --*snip*--
    };
    return Game;
  })(jQuery);

我们在游戏开始时绘制分数和关卡 ➊。当气泡被消除时,我们首先要生成一组所有被消除且孤立的气泡。这通过连接两个数组——已消除的列表和孤立的列表 ➋ 来完成,然后将 POINTS_PER_BUBBLE 乘以新数组的长度 ➌。接着,我们内部增加分数 ➍,但只有在气泡在 delay 结束时射击完成后,才会更新显示 ➎。如果你重新加载并开始游戏,你的分数应该会递增。

接下来,我们将检查游戏结束条件。两种状态可能导致游戏结束:玩家可能用尽气泡,或者玩家可能清空了棋盘上的所有气泡。如果是前者,我们希望显示玩家的最终得分,并让他们从第一关开始新游戏。如果是后者,我们希望清空棋盘,增加关卡数,并提示开始下一关。

我们知道游戏状态只会在玩家射出气泡后改变,所以我们唯一需要检查是否存在游戏结束条件的地方是碰撞结果计算之后。我们将在气泡射出后立即进行检查,这个过程发生在 Game 中的 clickGameScreen 内。如果棋盘为空或玩家已用尽气泡,我们将结束游戏;如果没有,我们将给玩家下一个要射击的气泡。请在 game.js 中做如下更改:

game.js

  var BubbleShoot = window.BubbleShoot || {};
    BubbleShoot.Game = (function($){
    var Game = function(){
      --*snip*--
      var clickGameScreen = function(e){
        --*snip*--
        BubbleShoot.ui.fireBubble(curBubble,coords,duration);
➊      **if(numBubbles == 0){**
          **endGame(false);**
➋      **}else if(board.isEmpty()){**
          **endGame(true);**
➌      **}else{**
          curBubble = getNextBubble(board);
        **}**
      };
      --*snip*--
    };
    return Game;
  })(jQuery);

我们首先检查玩家是否已经没有气泡 ➊,然后检查棋盘上是否没有气泡 ➋。如果都不成立,我们将像往常一样获取下一个气泡 ➌。一个名为 endGame 的新函数使用布尔值来判断玩家是赢了还是输了:false 表示玩家输了(因为气泡用完了),true 表示玩家赢了(因为清空了棋盘)。

注意调用 board.isEmpty,这是一个我们尚未编写的方法。现在让我们通过将以下内容添加到 board.js 类中来实现它:

board.js

var BubbleShoot = window.BubbleShoot || {};
BubbleShoot.Board = (function($){
  var NUM_ROWS = 9;
  var NUM_COLS = 32;
  var Board = function(){
    var that = this;
    --*snip*--
    **this.isEmpty = function(){**
      **return this.getBubbles().length == 0;**
    **};**
    return this;
  };
  --*snip*--
  return Board;
})(jQuery);

isEmpty 函数检查是否调用 getBubbles 方法返回任何对象。如果数组的长度为零,则说明所有气泡已被消除。

第二个可能的游戏结束条件是玩家在棋盘底部添加超过两行新行。我们已经有一个函数 getRows 用于返回行数组,所以我们只需要检查其长度是否大于我们允许的最大行数,即 11。

game.js

  var BubbleShoot = window.BubbleShoot || {};
    BubbleShoot.Game = (function($){
    var Game = function(){
    var curBubble;
    var board;
    var numBubbles;
    var bubbles = [];
    var MAX_BUBBLES = 70;
    var POINTS_PER_BUBBLE = 50;
➊  **var MAX_ROWS = 11;**
      --*snip*--
      var clickGameScreen = function(e){
        --*snip*--
        BubbleShoot.ui.fireBubble(curBubble,coords,duration);
➋      **if(board.getRows().length > MAX_ROWS){**
          **endGame(false);**
        **}else** if(numBubbles == 0){
          endGame(false);
        }else if(board.isEmpty()){
          endGame(true);
        }else{
          curBubble = getNextBubble(board);
        }
      };
      --*snip*--
    };
    return Game;
  })(jQuery);

为了使代码易于阅读,我们将允许的最大行数存储在一个名为 MAX_ROWS 的变量中 ➊,然后我们检查棋盘上的行数是否大于这个数字 ➋;如果是,我们将结束游戏。

我们还需要向玩家显示一些消息,指示胜利或失败、得分等。如果需要显示大量不同的消息,我们可能会编写一些 JavaScript 代码来动态创建和显示对话框。但我们只有几种变化,因此我们将它们硬编码到 HTML 中。我们将显示的对话框将与启动游戏时的对话框相同,但会有更多的信息,如图 7-2 所示。

游戏结束对话框

图 7-2. 游戏结束对话框

现在让我们将结构添加到 index.html 文件中:

index.html

  <!DOCTYPE HTML>
  <html lang="en-US">
  <head>
    --*snip*--
  </head>
  <body>
  <div id="page">
    --*snip*--
    <div id="start_game" class="dialog">
      <div id="start_game_message">
        <h2>Start a new game</h2>
      </div>
      <div class="but_start_game button">
        New Game
      </div>
    </div>
➊  **<div id="end_game" class="dialog">**
      **<div id="end_game_message">**
        **<h2>Game Over</h2>**
➋      **<div id="final_score">**
          **<span>Final Score:</span>**
          **<span id="final_score_value"></span>**
        **</div>**
➌      **<div id="new_high_score">New High Score!</div>**
➍      **<div id="level_failed" class="level_failed">Level Failed!</div>**
➎      **<div id="level_complete" class="level_complete">Level Complete!</div>**
      **</div>**
➏    **<div class="but_start_game button">**
➐      **<span class="level_complete">Next Level</span>**
➑      **<span class="level_failed">New Game</span>**
      **</div>**
    **</div>**
  </div>
  </body>
  </html>

我们的游戏只会显示一个对话框 ➊,其中包含最终得分的消息 ➋,以及关卡是完成还是失败。如果玩家达到了新的高分,我们将显示该消息 ➌。Level Failed! ➍ 或 Level Complete! ➎ 消息将根据情况显示。最后,一个按钮将启用下一场游戏的开始 ➏,这将导致进入下一关 ➐ 或全新游戏 ➑。我们可以在点击按钮后确定是重新开始游戏还是继续进行,因为我们知道当前的关卡编号。

当我们显示 end_game 对话框时,我们将根据需要显示或隐藏 level_completelevel_failed 类,以显示正确的消息。注意,level_complete 类附加在 Level Complete! 消息 ➎ 和 Next Level 按钮 ➐ 上,而 level_failed 类则附加在 Level Failed! 消息 ➍ 和 New Game 按钮 ➑ 上。这使我们可以通过一次 jQuery 调用隐藏所有 level_failed 元素:

$(".level_failed").hide();

这是使用 HTML 和 CSS 构建用户界面的一个优点,之所以可行,是因为泡泡射手是一个相对简单的游戏。但是,即便你需要在对话框中展示更多的消息,仍然可以使用 jQuery 来创建 DOM 元素并利用 CSS 进行样式设置。

对话框将继承 dialog 类定义中的一些样式,但我们需要向 main.css 添加更多的定义:

main.css

#final_score
{
  margin: 26px 0;
}
  #end_game_message span
  {
    margin-right: 20px;
    font-size: 24px;
  }
  #level_complete,#level_failed,#new_high_score
  {
    font-size: 36px;
    color: #fff;
  }

我们现在要在 game.js 中创建 endGame 函数。这个函数将显示游戏结束的对话框,显示适当的胜利或失败消息,然后允许玩家继续玩下一关或重新开始新游戏:

game.js

  var BubbleShoot = window.BubbleShoot || {};
    BubbleShoot.Game = (function($){
    var Game = function(){
      --*snip*--
      var renderFrame = function(){
        --*snip*--
      };
      **var endGame = function(hasWon){**
➊      **if(score > highScore){**
➋       **highScore = score;**
➌       **$("#new_high_score").show();**
➍       **BubbleShoot.ui.drawHighScore(highScore);**
        **}else{**
➎        **$("#new_high_score").hide();**
        **};**
➏      **if(hasWon){**
          **level++;**
➐     **}else{**
          **score = 0;**
          **level = 0;**
        **};**
➑      **$(".but_start_game").click("click",startGame);**
➒      **$("#board .bubble").remove();**
        **BubbleShoot.ui.endGame(hasWon,score);**
      **};**
    };
    return Game;
  })(jQuery);

首先,我们检查玩家的分数是否高于 highScore 的值,highScore 初始值为零 ➊。如果是,highScore 将更新 ➋,并且我们会在游戏完成对话框中显示 new_high_score 元素 ➌。然后调用 ui.drawHighScore,这是我们在更新游戏内得分显示时创建的 ➍。如果没有新的高分,消息将被隐藏 ➎。

接下来的分支检查玩家是否获胜,如果是的话 ➏,则将 level 增加 1。如果玩家失败,scorelevel 会重置为零 ➐。接着,我们需要通过将 click 事件绑定到 startGame 按钮上 ➑ 来重新启用它,清除显示中的其余气泡 ➒,并调用 ui.js 中的一个新方法来显示游戏结束的对话框。

请注意,无论玩家是玩第一个关卡还是第五十个关卡,这都无关紧要,因为 startGame 只是绘制当前关卡并开始游戏;因此,我们不需要为新关卡创建一个新函数。

但是,显示并不是游戏中唯一需要响应游戏结束的部分。玩家也不应该再能发射气泡!我们还需要在 ui.js 中创建一个名为 endGame 的函数。与 game.js 中的 endGame 处理结束关卡的游戏逻辑方面不同,ui.js 中的代码将处理结束游戏的视觉方面,如显示对话框并填充玩家的分数:

ui.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.ui = (function($){
    --*snip*--
    var ui = {
      --*snip*--
      **endGame : function(hasWon,score){**
➊      **$("#game").unbind("click");**
➋      **BubbleShoot.ui.drawBubblesRemaining(0);**
➌      **if(hasWon){**
          **$(".level_complete").show();**
          **$(".level_failed").hide();**
        **}else{**
          **$(".level_complete").hide();**
          **$(".level_failed").show();**
        **};**
➍      **$("#end_game").fadeIn(500);**
        **$("#final_score_value").text(score);**
      **}**
    };
    --*snip*--
    return ui;
  } )(jQuery);

当游戏结束时,endGame 方法确保在游戏区域的点击 ➊ 不再触发 clickGameScreen 函数,因为我们不希望玩家在游戏结束后继续发射气泡。它还会将剩余气泡的显示更新为零 ➋,并在对话框内显示正确的胜负消息 ➌。然后,我们展示带有“关卡完成!”或“关卡失败!”信息的对话框 ➍。

高效结束关卡

目前,Bubble Shooter 的游戏结束可能有点乏味:玩家被迫继续发射气泡,直到它们组合成足够大的群体来爆破。如果气泡的颜色组合不正确,这也可能会成为问题。例如,如果板上的唯一气泡是蓝色的,而随机生成器只生成红色气泡,玩家可能会因无法避免的原因而失败!与其让玩家清除每个气泡,我们会在他们清除完除最上面一排剩余的五个气泡外的所有气泡时,快速结束游戏。当这种情况发生时,剩下的顶行气泡将会爆破,其他气泡会像孤立群体一样掉落(使用 kaboom 例程)。

预见并缓解玩家的挫败感

永远提前考虑你的游戏可能会让玩家感到挫败的地方,并提前解决这个问题。这样,你就能改善游戏体验,让玩家愿意回头继续玩。在 Bubble Shooter 中,某些关卡可能因为气泡没有按正确顺序出现而无法完成。这种情况是一个完美的例子,说明了在原始游戏设计中没有考虑到的可能结果——在这种情况下,剩下一个气泡在板上且无法爆破——会发生什么。游戏编程几乎总是迭代性的,你的第一个版本很少是最终版。

在我们计算出当前需要消除的泡泡集合后,每当玩家消除泡泡时,我们会检查剩余的泡泡数量。如果玩家射击泡泡后,棋盘上剩余五个或更少的泡泡,我们会免费消除这些泡泡,并直接带玩家进入游戏结束界面。

判断关卡是否接近完成的检查将会放在clickGameScreen函数中,位置在game.js文件里:

game.js

  var BubbleShoot = window.BubbleShoot || {};
    BubbleShoot.Game = (function($){
    var Game = function(){
      --*snip*--
      var clickGameScreen = function(e){
        --*snip*--
        if(collision){
          var coords = collision.coords;
          duration = Math.round(duration * collision.distToCollision /
            distance);
          board.addBubble(curBubble,coords);
          var group = board.getGroup(curBubble,{});
          if(group.list.length >= 3){
            popBubbles(group.list,duration);
➊          **var topRow = board.getRows()[0];**
➋          **var topRowBubbles = [];**
            **for(var i=0;i<topRow.length;i++){**
              **if(topRow[i])**
                **topRowBubbles.push(topRow[i]);**
            **};**
➌          **if(topRowBubbles.length <= 5){**
➍            **popBubbles(topRowBubbles,duration);**
➎            **group.list.concat(topRowBubbles);**
            **};**
            var orphans = board.findOrphans();
            var delay = duration + 200 + 30 * group.list.length;
            dropBubbles(orphans,delay);
➏          var popped = [].concat(group.list,orphans);
            var points = popped.length * POINTS_PER_BUBBLE;
            score += points;
            setTimeout(function(){
              BubbleShoot.ui.drawScore(score);
            },delay);
          };
        }else{
          --*snip*--
        };
        --*snip*--
      };
      --*snip*--
    };
    return Game;
  })(jQuery);

首先,我们获取最上面一行的泡泡 ➊,然后我们遍历这一行,统计泡泡的数量 ➋。如果存在五个或更少的泡泡 ➌,我们会消除这一行的所有泡泡 ➍,并将它们加入已消除泡泡的列表 ➎,这样它们就会计入玩家的得分 ➏。

现在你应该能够完成一个完整的游戏关卡,清除棋盘,并看到提示开始下一关。恭喜!你刚刚完成了第一个完整可玩的游戏。

但是在你把泡泡射手展示给其他玩家之前,先让我们确保高分在每次游戏会话间持久保存,而不是每次关闭浏览器窗口时都重置。毕竟,如果不能回来挑战高分,那高分还有什么意义呢?

使用 Web 存储保持高分

尽管泡泡射手没有服务器端组件来保存高分,但我们可以利用 HTML5 提供的 Web 存储系统将高分保存到本地计算机中。使用相同浏览器再次游戏的玩家将看到上次的高分,这给他们提供了一个挑战目标。

泡泡射手是一个休闲游戏:玩家会打开它,玩几个关卡直到失败,然后关闭浏览器标签。记住高分是个不错的主意,但我们不需要保留其他数据。无论如何,即使你要存储更多信息,使用 Web 存储来保持数据从一个游戏会话到另一个会话的持久性原理也是一样的。

Web 存储与 Cookies

在客户端,Web 存储的行为与 Cookies 类似,但实现细节(以及优点)大不相同。Web 存储比 Cookies 更容易访问,因为数据以名称/值对的形式存储。与 Cookies 不同,Web 存储的内容不会通过 HTTP 请求传输,因此没有服务器端访问权限。存储的内容受域名限制,不同的子域名拥有不同的存储空间。我们本可以将高分存储在 Cookie 中,但没有必要这么做,存储格式以及每次请求时不必要地将数据传输到服务器的开销,使得 Cookie 比 Web 存储更不合适。尝试将大量数据(例如当前棋盘的布局)存储在 Cookie 中也可能导致性能问题,因为这些数据会随每个请求一起传输到服务器。例如,当浏览器尝试下载一个仅几 KB 的图像文件时,可能还会不得不把大量多余的数据一起发送给服务器。

而 Web 存储提供的空间比 cookies 更多,尽管在 HTML 规范中并未定义具体数量,浏览器厂商单独设定了该限制。目前,主流浏览器之间的最低共同限制是 5MB;该限制适用于一个域名下的所有数据。桌面版 Google Chrome、Firefox 和 Internet Explorer 9 提供最多 10MB 的存储,而安卓设备的浏览器仅提供 2MB。与最大 cookie 存储量(每个 cookie 4KB,最多可存储 300 个)相比,即使在最低限制下,Web 存储提供的空间也大得多。

由于浏览器限制可能会定期变化,如果你打算将大量数据存储到 Web 存储中,最好的做法是测试特定设备;然而,对于像 Bubble Shooter 这样的高分这种小型元素,空间限制不重要。

向 Web 存储中添加数据

Web 存储分为两部分:会话存储(Session Storage)和本地存储(Local Storage)。我们只看本地存储,它最适合在会话间持久化数据。存储和访问数据的原则在会话存储中大体相同,尽管持久性和安全性有所不同。顾名思义,会话存储仅在浏览器会话期间有效,用户关闭浏览器窗口时数据会消失。这种存储方式可能对多页面的 Web 应用程序有用,其中数据需要从一页传递到下一页,但显然不适合存储高分。一旦你熟悉了本地存储,你就能够在需要使用会话存储时适应它。

localStorage添加数据的格式如下:

localStorage.setItem(key,value);

key是一个字符串,例如"high_score"value也是一个字符串,或者是可以自动转换为字符串的数字或其他对象。需要注意的是,如果你尝试传入复杂对象,如数组,转换为字符串可能会得到对象的名称(即Array),而不是你想要存储的数据。因此,如果有疑问,最好自己进行转换。对于更复杂的数据,可以使用JSON.stringify保存对象,使用JSON.parse来检索它们。

要检索数据,你只需要key

var value = localStorage.getItem(key);

localStorage.getItem总是返回字符串值,因此你需要使用parseIntparseFloat将其转换为数字数据。

如果游戏更复杂或需要更长时间来玩,你可能希望保存更多数据,比如当前关卡以及最高分。在这种情况下,我们可以继续添加字符串:

localStorage.setItem("high_score",highScore);
localStorage.setItem("level",level);

或者我们可以创建一个对象并对其进行 JSON 编码:

var gameData = {high_score : highScore, level : level};
localStorage.setItem("bubbleshoot_data",JSON.stringify(gameData));

然后,当我们想要检索数据时,我们会使用这个:

var gameData = JSON.parse(localStorage.getItem("bubbleshoot_data"));

一般原则是,如果你能将数据转换为字符串,并且在需要检索数据时从字符串中解码它,你就可以将数据保存到本地存储中。

泡泡射手游戏中,为了保存最高分,Local Storage 条目将被命名为high_score。在游戏初始化时,我们需要检查是否已有现有的值存储,如果有的话,就使用该值替代当前硬编码的零。当玩家创造新纪录时,我们会将 Local Storage 中的值更新为新的最高分。

game.js中,我们将对initendGame进行修改,以获取并设置最高分:

game.js

  var BubbleShoot = window.BubbleShoot || {};
    BubbleShoot.Game = (function($){
    var Game = function(){
      --*snip*--
      this.init = function(){
        if(BubbleShoot.Renderer){
          BubbleShoot.Renderer.init(function(){
            $(".but_start_game").click("click",startGame);
          });
        }else{
          $(".but_start_game").click("click",startGame);
        };
➊      **if(window.localStorage && localStorage.getItem("high_score")){**
➋        **highScore = parseInt(localStorage.getItem("high_score"));**
        **}**
➌      **BubbleShoot.ui.drawHighScore(highScore);**
      };
      --*snip*--
      var endGame = function(hasWon){
        if(score > highScore){
          highScore = score;
          $("#new_high_score").show();
          BubbleShoot.ui.drawHighScore(highScore);
➍        **if(window.localStorage){**
➎          **localStorage.setItem("high_score",highScore);**
          **}**
        }else{
          $("#new_high_score").hide();
        };
        if(hasWon){
          level++;
        }else{
          score = 0;
          level = 0;
        };
        $(".but_start_game").click("click",startGame);
        $("#board .bubble").remove();
        BubbleShoot.ui.endGame(hasWon,score);
      };
    };
    return Game;
  })(jQuery);

首先,我们检查浏览器是否支持localStorage,通过使用另一个 Modernizr 检测器,查看是否存在high_score的值 ➊。如果存在最高分,我们将highScore设置为存储中的内容 ➋。我们确保将该值用parseInt包装,因为存储中的值是以字符串形式返回的,而我们想要使用整数。然后,我们展示最高分 ➌。为了保存分数,我们在endGame中添加一行,检查是否支持localStorage ➍,然后将数据保存到其中 ➎。

重新加载浏览器并玩一局游戏。首先,任何你得到的分数应该都会成为新的最高分。但如果你关闭浏览器并重新加载游戏,最高分应该会显示为你之前的值。

你还可以使用 Web Storage 来保存像语言偏好、玩家档案或游戏状态进度等内容。只要注意你存储的内容,因为存储系统中的值可以被 JavaScript 控制台调用。这意味着没有什么能阻止那些技术比较熟练的玩家自己更新数据!在下一章,我们会简要讨论 HTML5 游戏中的安全问题,但目前我们可以依赖这样一个事实:实际上没有人有动力设定一个不可能的高分来挑战。

使用 requestAnimationFrame 平滑动画

我们在jquery.kaboom.js中使用setTimeout来计时动画,并在泡泡射手的画布版本中触发帧更新。setTimeout具有跨浏览器兼容性,且相对简单:将超时值设置为 40 毫秒,就可以达到每秒 25 帧的效果。

然而,使用setTimeout也有一些缺点。主要的问题是,如果浏览器正忙于其他任务,下一个迭代可能会延迟超过 40 毫秒。在某些情况下,可能需要更长时间,而用户会开始注意到这一点。

我们可以重新编码移动逻辑,使得对象的移动距离与自上次更新以来的时间成正比,从而有效地忽略 40 毫秒的时间间隔。但我们仍然需要接受这样一个事实:无论我们将超时延迟设置为多少,一些配置较差的设备可能无法跟上。在能够处理更快更新的系统上,我们可以显示更流畅的动画,但如果我们将超时值设置为 10 毫秒以应对这些情况,较慢的系统就会出现负面效果。

幸运的是,HTML5 引入了requestAnimationFrame,它是setTimeout的替代方案,更适合动画。浏览器不会让程序员去猜测可能适用的帧率,而是每当准备好绘制新更新时,调用传递给requestAnimationFrame的函数。更新之间的时间可能比 40 毫秒快得多(或慢得多!),但至少我们知道我们既不会让处理瓶颈变得更糟,也不会让系统空闲着,而是可以利用额外的处理周期来平滑动画。

关于帧更新的新视角

在切换到requestAnimationFrame时,我们需要以不同的方式思考帧更新。目前,setTimeout运行之前,我们告诉浏览器等待多长时间。我们假设经过的时间就是我们预计要经过的时间。例如,在jquery.kaboom.js中的moveAll函数中,我们设置了一个 40 毫秒的超时:

setTimeout(moveAll,40);

然后,我们假设已经过去了 40 毫秒——1/25 秒——并更新气泡的位置。然而,在使用requestAnimationFrame时,我们并没有指定帧率。在jquery.kaboom.js中的moveAll函数中,如果requestAnimationFrame每 40 毫秒运行一次这个例程,我们就不需要做任何改变。但如果它每 20 毫秒运行一次,我们就不能保持相同的dxdy值,否则我们的整个动画将运行得更快——实际上是两倍的速度,因为它会以两倍的频率增加dxdy

相反,我们需要找出已经过去了多少毫秒,然后调整我们的动画步长。我们甚至可以将相同的数学方法应用于setTimeout动画,从而在不支持requestAnimationFrame的旧浏览器上获得更好的效果。如图 7-3 所示,自上次绘制气泡以来经过的时间越少,我们就需要沿路径移动气泡的距离越少。

不同帧率下的气泡位置

图 7-3. 不同帧率下的气泡位置

代码兼容性与 Polyfill

Modernizr 将帮助我们构建setTimeout的回退。由于requestAnimationFrame仍然被许多浏览器视为标准之前的技术,因此为 Webkit、Mozilla 等提供了带前缀的版本,而 Modernizr 可以为我们填充这些前缀。将以下内容添加到game.js

game.js

var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
  var Game = function(){
    --*snip*--
  };
  **window.requestAnimationFrame = Modernizr.prefixed("requestAnimationFrame",**
    **window) || function(callback){**
    **window.setTimeout(function(){**
      **callback();**
    **}, 40);**
  **};**
  return Game;
})(jQuery);

这一行新代码表示,如果requestAnimationFrame(如有必要,带厂商前缀)已定义,则将window.requestAnimationFrame设置为requestAnimationFrame的内容。如果requestAnimationFrame没有定义,则我们创建一个新函数,接受一个函数作为参数,并在 40 毫秒后使用setTimeout调用该函数。

这种技术被称为 polyfill。Polyfill 尝试在浏览器中模拟或补充新功能,尤其是那些浏览器原生不支持的功能,让你在代码中使用新技术,而不必总是担心需要分叉代码或自行提供回退方案。这个名字来自填充物 Polyfilla,因为这种技术涉及填补浏览器支持的空白。

Polyfills 被编写来支持旧版浏览器中的各种功能。例如,为了存储玩家的最高分,我们使用了本地存储 API。这在旧版浏览器中不可用,但我们可以通过将数据存储在 cookie 中来实现相同的效果。有两种方法可以解决这个问题:一种方法是每次访问本地存储时编写 if/else 语句,检查是否存在本地存储,如果没有,就分支执行一些 cookie 代码。另一种方法是创建一个名为 localStorage 的对象,并为 getItemsetItem 方法添加使用 cookies 存取数据的功能。

Polyfills 很少是完美的解决方案:setTimeoutrequestAnimationFrame 的操作方式非常相似,但有时这些差异可能是重要的。在本地存储的例子中,我们也许可以像使用本地存储一样使用 cookies,但如果我们尝试存储大量数据,就会遇到问题。Polyfills 可以增强浏览器兼容性,而无需大量代码,但了解你使用的 polyfill 的局限性也很重要。

一旦我们有了 requestAnimationFrame 的 polyfill,就像其他代码一样,我们可以在任何浏览器中使用 requestAnimationFrame。我们知道实际上 setTimeout 调用在幕后运行,有时动画可能不会像原生支持的 requestAnimationFrame 方法那样平滑运行。但是从调用它的代码角度来看,该函数的行为是一样的。

现在我们有了一个可用的 requestAnimationFrame polyfill,我们可以将 game.js 中对 setTimeout 的调用替换为 startGamerenderFrame 中对新函数的调用:

game.js

var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
  var Game = function(){
    --*snip*--
    var startGame = function(){
      --*snip*--
      if(BubbleShoot.Renderer)
      {
        if(!requestAnimationID)
          requestAnimationID = **requestAnimationFrame(renderFrame);**
      }else{
        BubbleShoot.ui.drawBoard(board);
      };
      --*snip*--
    };
    --*snip*--
  var renderFrame = function(){
    $.each(bubbles,function(){
      if(this.getSprite().updateFrame)
        this.getSprite().updateFrame();
    });
      BubbleShoot.Renderer.render(bubbles,board.calculateTop());
      requestAnimationID = **requestAnimationFrame(renderFrame);**
    };
    --*snip*--
  };
  --*snip*--
  return Game;
})(jQuery);

我们必须在 jquery.kaboom.js 中做类似的修改,使用 requestAnimationFrame 替代 setTimeoutkaboom 函数内部假设每帧之间的间隔是 40 毫秒,从而实现每秒 25 帧的帧率,但如我们现在所知,使用 requestAnimationFrame 时,间隔时间可能会有所不同。同样,我们需要计算已经过去的时间,并按比例计算运动:

jquery.kaboom.js

  (function(jQuery){
    var defaults = {
      gravity : 1.3,
      maxY : 800
    };
    var toMove = [];
➊  **var prevTime;**
    var moveAll = function(){
➋    **var newTime = Date.now();**
➌    **var elapsed = newTime - prevTime;**
➍    **var frameProportion = elapsed / 25;**
➎    **prevTime = newTime;**
      var stillToMove = [];
      for(var i=0;i<toMove.length;i++){
        var obj = toMove[i];
        obj.x += obj.dx * frameProportion;
        obj.y -= obj.dy * frameProportion;
        obj.dy -= obj.config.gravity * frameProportion;
        if(obj.y < obj.config.maxY){
          obj.elm.css({
            top : Math.round(obj.y),
            left : Math.round(obj.x)
          });
          stillToMove.push(obj);
        }else if(obj.config.callback){
          obj.config.callback();
        }
      };
      toMove = stillToMove;
      if(toMove.length > 0)
➏      **requestAnimationFrame(moveAll);**
    };
    jQuery.fn.kaboom = function(settings)
  {
    var elm = this;
    var config = $.extend({}, defaults, settings);
    if(toMove.length == 0){
      **prevTime = Date.now();**
➐    **requestAnimationFrame(moveAll);**
    };
    var dx = Math.round(Math.random() * 10) - 5;
    var dy = Math.round(Math.random() * 5) + 5;
    toMove.push({
      elm : this,
      dx : dx,
      dy : dy,
      x : this.position().left,
      y : this.position().top,
      config : config
    });
  };
})(jQuery);

首先,我们定义一个名为 prevTime 的空变量 ➊,用来存储上次渲染的帧的时间戳,初始值为 null。每次调用 moveAll 时,我们获取当前时间戳 ➋ 并计算自上次帧以来经过的时间 ➌。我们最初的计算基于已经经过了 40 毫秒,因此为了计算正确的位置,我们相应地缩放帧的经过比例 ➍。如果只有 8 毫秒已经过去,frameProportion 将是 0.2,动画会更新得更小且更频繁。如果已经过去 80 毫秒,frameProportion 将是 2,动画会以较大的步伐更新。最终效果是,不管帧率如何,气泡都需要相同的时间才能从屏幕上掉落。为了准备下一个帧,我们将 prevTime 更新为当前时间戳 ➎。

此外,setTimeout 在两个地方被 requestAnimationFrame 替代:一次是在动画开始时 ➏,一次是在每个帧循环时 ➐。

重新加载游戏并再次运行它,确保它正常工作。除非你有特别慢的浏览器设置,否则你可能不会看到性能上的差异。然而,现在你可以放心地知道,所有玩 Bubble Shooter 的玩家都将看到气泡以相同的速度移动和掉落,即使不同设备之间的帧更新率有所不同。

使用 HTML5 添加声音

没有声音,游戏就不像游戏!HTML5 提供了一些越来越强大的选项来处理和播放音频。我说 越来越强大 是因为浏览器的支持一直在不断提升。你可以逐字节操作波形文件、从麦克风录音、进行动态混音,并利用许多其他功能,远远超过 HTML 以前提供的可悲的音频选项。让我们看看 HTML5 音频的基本功能。

HTML5 音频 API

历史上,HTML 实现音频的方式非常差,没有提供可靠的方式来在网页中嵌入和控制声音。HTML5 改变了这一点,你可以通过一个简单的标签将声音直接嵌入页面,如下所示:

<audio src="sounds.mp3" autoplay></autoplay>

单独来看,这对于一个我们希望以编程方式启动和停止声音的游戏帮助不大,因为声音需要响应像气泡爆炸这样的事件。幸运的是,HTML5 还提供了一种通过 JavaScript API 播放音频的方法,完全不需要使用 HTML 标签。

上述 HTML 片段的 JavaScript 等效版本,它仅嵌入并播放一个单一的文件,如下所示:

var sound = new Audio("sounds.mp3");
sound.play();

你可以使用任何 MP3 文件来试试这个示例。传递给new Audio调用的参数是声音文件的 URL。如果你把它放在bubbleshoot文件夹中,并将参数更改为文件名,你可以在 JavaScript 控制台中运行前面的命令,声音应该会播放。

当声音结束时,它会自然停止,你可以使用 stop 方法在播放的任何时刻停止声音:

sound.stop()

这些是我们需要的唯一命令,但可以花时间浏览音频 API 规范,了解浏览器中声音传输的更多潜力。除了影响音频基本播放的各种方法和属性(例如更改音量或跳转到文件中的特定位置)外,还有许多功能,如从输入设备录音、混音、改变立体声,甚至是 3D 声音定位,以及后期处理音效以添加回声等效果。这些功能越来越多地被主流浏览器支持,例如 Google Chrome 和 Firefox,每个新版本都会带来改进。

如果你想同时播放多个声音,必须创建多个 Audio 对象。例如:

var sound1 = new Audio("sounds.mp3");
var sound2 = new Audio("sounds.mp3");
sound1.play();
sound2.play();

如果只是想依次播放不同的声音,你可以通过更改 Audio 对象的 src 属性来重用该对象。但是,要同时播放多个声音,你需要为计划同时播放的每个声音创建一个 Audio 对象。正如你在 Bubble Shooter 中看到的那样,这意味着如果我们想要打破一组 20 个气泡,我们需要 20 个声音对象来同时播放这 20 个气泡破裂的声音。

打破气泡:配上声音

我们将使用音频 API 向 Bubble Shooter 添加 HTML5 音频支持,使每次气泡被打破时都会播放声音。从 www.buildanhtml5game.com/ 下载 pop.mp3 文件,并将其放入游戏文件夹中的一个名为 _mp3 的新文件夹中。

首先,创建一个类来播放声音。我们将把 HTML5 音频功能封装在我们自己的代码中,这样可以防止在不支持 HTML5 音频的浏览器中抛出错误。在 _js 文件夹中创建一个名为 sounds.js 的新文件,然后将其添加到 index.html 中。声音处理(如渲染和用户界面)是一个功能模块,最好在可能的情况下将其与游戏逻辑分开。通过创建一个单独的文件来处理播放,我们可以将所有音频处理代码集中在一个地方。

我们将重用 Audio 对象,因此会在代码初始化时创建这些对象。然后,每当需要播放声音时,我们就从队列中取出下一个对象,将 src 属性更改为我们想播放的文件,然后播放它。我们将设置一个最大同时播放声音数为 10,这是一个较小的数字,但即使在极少数情况下,玩家一次打破超过 10 个气泡,也不需要播放超过 10 个声音。

sounds.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Sounds = (function(){
➊  var soundObjects = [];
➋  for(var i=0;i<10;i++){
      soundObjects.push(new Audio());
    }
➌  var curSoundNum = 0;
➍  var Sounds = {
➎    play : function(url,volume){
        if(Modernizr.audio){
➏        var sound = soundObjects[curSoundNum];
➐        sound.src = url;
➑        sound.volume = volume;
➒        sound.play();
➓        curSoundNum++
          if(curSoundNum >= soundObjects.length){
            curSoundNum = curSoundNum % soundObjects.length;
          }
        }
      }
    };
    return Sounds;
  })();

一个名为 BubbleShoot.Sounds 的新对象包含了数组 soundObjects ➊,我们将用它来存储十个 Audio 对象。这些对象在代码加载后立即初始化 ➋。我们还通过变量 curSoundNum ➌ 来追踪当前使用的对象。

接下来,我们创建一个对象来播放声音 ➍,它包含一个方法来播放声音 ➎。它将接受两个参数:要播放的声音文件的 URL 和播放声音的音量值,音量值是一个在 0(静音)和 1(最大音量)之间的十进制数。

我们使用 Modernizr 来检查是否支持 HTML5 音频,如果支持,我们就从soundObjects数组中获取当前的Audio对象 ➏,将其src属性设置为要播放的文件的 URL ➐,设置它的音量 ➑,然后播放它 ➒。如果不支持音频,该方法将不做任何操作,但由于我们检查了Modernizr.audio,因此不会抛出错误。

最后,我们增加curSoundNum的值 ➓,这样下一次调用play时,我们就能从队列中获取下一个对象。然后,我们确保curSoundNum的值永远不会大于soundObjects数组中sound对象的数量。

如果我们想播放更多的声音,可以将更多的Audio对象推入soundObjects数组。目前,如果我们尝试一次播放超过 10 个声音,只有最后 10 个声音会被播放。

音效控制将在game.js中通过调用BubbleShoot.Sounds.play函数来完成:

game.js

  var BubbleShoot = window.BubbleShoot || {};
  BubbleShoot.Game = (function($){
    var popBubbles = function(bubbles,delay){
      $.each(bubbles,function(){
          var bubble = this;
          setTimeout(function(){
            bubble.setState(BubbleShoot.BubbleState.POPPING);
            bubble.animatePop();
            setTimeout(function(){
              bubble.setState(BubbleShoot.BubbleState.POPPED);
            },200);
➊          **BubbleShoot.Sounds.play("_mp3/pop.mp3"**➋**,Math.random()*.5 + .5**➌**);**
          },delay);
          board.popBubbleAt(bubble.getRow(),bubble.getCol());
          setTimeout(function(){
            bubble.getSprite().remove();
          },delay + 200);
          delay += 60;
        });
      };
      --*snip*--
    };
    --*snip*--
    return Game;
  })(jQuery);

我们希望播放与气泡数量相同的声音,并且还希望在启动动画的同时开始播放声音➊。我们将Sounds的播放方法传入两个参数:要播放的 MP3 文件的相对 URL ➋ 和音量值,它是一个在 0.5 到 1 之间的随机数 ➌。

增加沉浸感的多样性

为什么我们要传递一个随机的音量值?试着传入一个值为 1 并弹出一些气泡。然后将这个效果与随机值的效果进行比较。这只是一个小的变化,但音量的变化提供了足够的差异,使得每个声音听起来稍微不那么机械。我们还可以做其他事情使效果更加自然,比如使用一组声音而不是仅仅一个 MP3 文件,这样就不会每个气泡都听起来一样,或者调整爆泡的时间间隔,让它们不再是均匀间隔的。通过实验来创造最具沉浸感的体验,并以最小的努力做到这一点,是随着你开发更多游戏,你会变得更加熟练的任务。

总结

现在,我们已经有了一个简单的声音来增添一些气氛,你已经完成了Bubble Shooter的构建!这个游戏应该可以在较旧的浏览器上运行,使用 CSS 进行定位和动画,并且在支持canvas元素的较新浏览器上也能正常运行。我们有持久的高分和音频,并且我们已经以这样的方式开发了动画,使得无论玩家的系统速度如何,它们都应该表现良好。

在下一章中,我们将探索一些与您刚刚构建的游戏不直接相关的 HTML5 的其他部分。你将学习如何将游戏部署到 Web 和移动环境,并且你将了解 HTML5 的未来发展方向。

进一步练习

  1. 在每个关卡结束时,玩家的棋盘上只能剩下 1、2 或 3 种颜色的气泡。如果给他们一个无法与这些颜色匹配的气泡,会导致玩家浪费一次射击,并使游戏更难完成。修改气泡生成算法,使得玩家只能获得可能形成匹配的气泡。例如,如果只剩下红色和蓝色气泡,发射的气泡应该是红色或蓝色。你需要修改 game.js 中的 getNextBubble 方法,并从 Board 对象中现有的气泡类型中选择一个 type

  2. 正如在多个关卡与高分中所提到的,游戏在经过几个关卡后会变得无法继续,因为允许的气泡数量变得过小。为了避免每个关卡减少五个气泡的设定,可以创建一个算法,使得关卡逐渐变难,但仍然可以完成。比如,玩家能完成一个关卡所需的最少气泡数为 30,我们希望他们在第 15 关达到这个难度。在这之前,从第 1 关到第 2 关的气泡减少量可能是 5 个,而从第 14 关到第 15 关的减少量可能仅为 1 个。编写一个方程或其他方法来减少允许的气泡数量,并以此方式增加难度。

  3. 通过为完成度评分而不是当前的通过或失败条件来激励玩家重复关卡。你可以在玩家通关时奖励 1 星,如果他们在剩余超过 25% 气泡的情况下通关,奖励 2 星;如果他们仅用一半的气泡完成关卡,奖励 3 星。向关卡完成对话框中添加信息,显示玩家获得了多少星。

  4. 一旦添加了前述的星级系统,创建一个方法来存储玩家在每个关卡中获得的星数。这样你就可以不仅显示玩家获得的星数,还可以在他们打破之前的最佳成绩时,显示一条消息。目前,我们将剩余气泡数、玩家得分和当前关卡号作为变量存储在 Game 中。但现在,最佳的方法可能是创建一个对象,存储每个关卡并记录星数。将这些数据保存到 Local Storage 中,以便玩家下次返回游戏时使用。

  5. 编写一个 polyfill 来为旧版浏览器添加 Local Storage 支持,使用 cookies 实现。如果 window.localStorage 对象不存在,你需要创建它,并实现 getItemsetItem 方法。

第八章:HTML5 的下一步

除了图形方面的进步外,HTML5 还有许多其他特性,使其成为一个强大的游戏开发环境。在本章中,我将讨论其中的一些特性,帮助你了解可用的功能,并为进一步阅读提供一些有用的资源。其中一些特性,例如 WebGL,是值得单独成书的主题,而另一些则只对某些类型的游戏有用。因此,我这里只介绍这些概念,具体的深入探索留给你自己去做。

保存和检索数据

人们像玩 Bubble Shooter 这样的游戏时,通常是在短时间内进行的,并且几乎没有或没有持久数据;实际上,我们的游戏只保存从一次会话到下一次会话的最高分。目前,最高分是存储在 Web Storage 中,因此它对游戏所在的浏览器是唯一的。为了保存一个全局的最高分并显示最高分表,我们需要编写一个服务器端组件,将分数发送到服务器并检索一个最高分列表。

更复杂状态的游戏也应该有服务器端访问。当你将状态存储在服务器上时,玩家可以从多个设备返回到相同的游戏。为了我们的目的,我们将使用两种主要方式在服务器上保存和检索数据:AJAX 和 WebSockets。

AJAX

AJAX(异步 JavaScript 和 XML) 提供了一种向服务器发送请求并接收响应的技术。AJAX 不是单一的技术,而是一种将多种经过验证的浏览器功能结合起来,以进行服务器端调用和管理响应的方法。所有主流浏览器已经支持 AJAX 多年。

尽管 X 代表 XML,但你可以使用 AJAX 检索 HTML 数据、字符串数据和可以解析和解释的 JSON 字符串。AJAX 调用的代码有详细的文档,并且有多个库可以使用,因此你不必手动编写调用代码。例如,以下是如何使用 jQuery 中的 $.ajax 调用向服务器发送 AJAX 请求的方式:

    $.ajax({
➊    url : "save_data.php",
➋    data : "high_score =" + highScore,
➌    type : "POST",
➍    complete : function(data){
        console.log(data);
    }
  });

这个 $.ajax 调用向相对 URL save_data.php 发起一个 POST 请求,将 highScore 中的值以 high_score 为名称发送到服务器,并将服务器的响应记录到控制台。我设置了请求的目标 URL ➊,发送的数据 ➋,请求类型 ➌,以及请求完成后运行的函数 ➍,但你可以设置许多其他属性,包括在出现错误时运行的函数、超时设置等。这些属性在 jQuery 文档中的 api.jquery.com/ 有列出。

注意

AJAX 中的 A 代表 异步 ,因为在服务器处理数据并发送响应时,其他 JavaScript 操作将继续进行。这意味着你无法确定 complete 函数何时运行:它会在响应返回时执行,但在此过程中,用户界面仍会保持响应。虽然可以进行同步调用,但由于这样会在请求完成之前冻结整个页面,因此用户体验通常很差,所以这种做法通常被认为是不推荐的。

WebSockets

大多数现代浏览器也支持 WebSockets 用于客户端与服务器之间的调用。WebSockets 是一种相对较新的技术,已被纳入 HTML5 规范。如果你想了解它们如何工作,超越我在此描述的内容,可以从 Mozilla 开发者网络的文档开始,网址是 developer.mozilla.org/en/docs/WebSockets/

WebSockets 类似于 AJAX,但不同的是,AJAX 在客户端和服务器之间建立了一个请求-响应的关系,而 WebSocket 在它们之间保持持久的连接。客户端处理响应时会立刻处理,并且 JavaScript 代码可以持续监听后续的响应。服务器也会在套接字连接保持打开时持续监听;因此,当客户端和服务器之间涉及大量小数据交换时,WebSockets 比 AJAX 要好得多。

持久连接在多人游戏环境中尤其有用。在 WebSockets 出现之前,更新多个客户端共享的游戏状态元素(例如玩家角色在环境中的状态)的主要方式是通过 AJAX 不断轮询服务器并检查是否有更新。这通常会被编写成每隔几秒钟执行一次,显然这对于实时游戏来说并不足够。人们尝试了各种技巧,例如 长轮询,它有效地欺骗客户端保持与服务器的连接,以改善这一过程,但这些方法通常在服务器资源方面效率不高。现在,你只需保持 WebSocket 连接打开,每当某个客户端更新游戏状态时,服务器就能立即更新所有其他客户端的信息,而不需要等待下一个更新周期。

主流浏览器对 WebSockets 的支持不断提升,和 AJAX 一样,我推荐使用库来消除打开连接、发送和监听数据、以及处理错误的繁琐细节。库还通常会为 WebSockets 不被支持的情况提供回退机制,回退机制可能会使用 AJAX 或其他服务器通信方法;然而,回退机制可能无法复制你最初使用 WebSockets 所希望达到的性能特性,因此要注意它们并不是万能的解决方案。

Socket.IO (socket.io/) 是最流行的 WebSocket 库之一。以下是如何使用它进行调用:

var socket = io.connect("http://localhost");
  socket.emit("new_high_score", {
    high_score : highScore
  });
});

这段代码使用 io.connect 调用库打开一个新的 WebSocket,然后 socket.emithighScore 值作为名为 new_high_score 的事件发送。

WebSocket 和像 Socket.IO 这样的库比 AJAX 更具功能性,但使它们易于使用的库通常假设一个特定的服务器端环境。如果你打算使用 WebSocket,检查你计划使用的库是否有与你的服务器环境匹配的后端组件。大多数平台的库都可以轻松找到,无论你使用的是 Node.js、.NET 还是 Java。

除了与服务器发送和接收数据外,你可能还想在主游戏程序之外处理某些数据。这时,Web Worker 将会派上用场。

Web Worker

浏览器中的 JavaScript 通常被认为是 单线程 环境,这意味着一次只能运行一个脚本。大多数时候这不会引起问题,但如果一个特别大的计算过程阻塞了处理器,导致动画无法播放、用户输入无法响应以及其他重要任务无法执行时,就可能成为问题。

比如,假设处理游戏级别数据需要浏览器花费 1 到 2 秒,每隔大约 30 秒就会发生一次。整体负载可能不高,但你不能每隔 30 秒就暂停游戏!在这种情况下,可以考虑使用 Web Worker。

Web Worker (developer.mozilla.org/en/docs/Web/API/Worker/)允许你在独立的线程中运行代码,而不会阻塞你的主 JavaScript 操作。它们之所以被称为“Worker”,是因为你基本上可以把一个任务交给它们,让它们在完成后报告结果。浏览器将决定给它们多少 CPU 时间,以避免对其他进程造成不必要的干扰。Worker 可以是专用的或共享的,但通常你会发现专用的 Worker 更加有用,尤其是在 Web Worker 跨浏览器支持逐步完善的过程中。

Web Worker 有几个规则,使它们与常规 JavaScript 区别开来。最重要的是,它们无法访问 DOM、浏览器文档或浏览器窗口。Worker 也在自己的作用域内运行,因此你需要显式传递数据,然后在完成时获取结果。我将通过以下示例来说明它们是如何工作的。

Worker 通过将要加载的脚本名称传递给 new Worker 命令来初始化:

var worker = new Worker("work.js");

这将启动一个新的 Worker,Worker 将在 work.js 中运行脚本。

当你通过 postMessage 发送消息时,Worker 会开始运行:

worker.postMessage();

postMessage 命令可以包含一个 JavaScript 对象,也可以为空。

你可以通过向调用脚本中的 Worker 添加事件监听器来处理响应——当 Worker 完成任务时返回的值:

worker.addEventListener("message", function(e) {
  console.log(e.data);
}, false);

在这里,e 包含了 worker 返回的数据。需要监听的事件是标记为 "message" 的任何有效字符串。因此,worker 可以根据不同的情况发送不同的响应,或者它可以继续工作并发送消息。

在 worker 内部,事件监听器的模型类似,worker 自我指代为 thisself。举个例子,work.js 可能包含如下内容来返回消息:

self.addEventListener("message", function(e) {
  self.postMessage({
    message : "I'm done now"
  });
}, false);

这段代码监听标记为 "message" 的事件,并在收到事件后立即以对象的形式发布响应。

目前,并非所有主流浏览器都足够好地支持 Web Workers 以使其可靠。确实存在 Web Workers 的 Polyfills,但如果一个原本认为是非阻塞的长期运行过程突然使游戏冻结几秒钟,这些 Polyfills 往往会对用户体验产生负面影响。然而,情况正在不断改善,希望 Web Workers 很快会成为 HTML5 游戏开发者工具箱的核心部分。

更有效地管理你的数据只是让游戏更有趣的开始。外观也很重要,若要进行图形升级,你可以使用 WebGL 实现 3D,或者甚至利用它提升 2D 游戏的渲染能力。

WebGL

对于 Bubble Shooter 的 canvas 版本,我们使用了 2D 渲染上下文,通过类似以下的调用方式来访问:

var context = canvas.getContext("2d");

正如我在第六章中提到的,"2d"的规范意味着还可以使用其他选项,有时根据浏览器支持情况,确实如此。第三维度通过 WebGL 访问,WebGL 是一个 API,提供了一组 3D 操作函数,用于创建场景、添加光照和纹理、定位相机等,利用现代显卡提供的加速功能。(访问 www.khronos.org/registry/webgl/specs/1.0/ 了解更多关于 WebGL 的详细信息。)要开始使用 WebGL,我们首先通过以下方式实例化一个 3D 上下文:

var context = canvas.getContext("webgl");

这有时会以 "experimental-webgl" 的形式返回,因此最兼容的调用是:

var context = canvas.getContext("webgl")
  || canvas.getContext("experimental-webgl");

加速的 WebGL 足够强大,可以显示完全渲染的 3D 场景,与原生游戏的场景相媲美。缺点是,工作在三维空间中,操作和创建场景需要大量的数学运算,以及大量低级代码,需要直接向图形处理器编写程序。其概念与在原生代码(如 C++)中创建 3D 游戏相同,并且需要具有 3D 建模的低级知识,以描述物体的形状;纹理来定义表面图案;以及着色器,用于描述当光线照射到表面时如何渲染它。因此,我强烈推荐使用现有的库来处理模型渲染、任何物理需求,以及基本上你能从现成工具中获得的任何功能。Babylon.js (www.babylonjs.com/) 和 PlayCanvas (playcanvas.com/) 是两个可以大大简化在浏览器中使用 WebGL 的库。

使用 WebGL 还引出了如何将对象和纹理导入 3D 场景的问题。通常,你会在建模软件中创建模型,如 3D Studio 或 Maya,然后导出为常见支持的格式。WebGL 库通常不支持这些格式,因此你通常需要使用其他工具集将原始 3D 建模文件格式转换为 JSON,例如 3DS Max 到 Babylon.js 的导出工具 (github.com/BabylonJS/Babylon.js/tree/master/Exporters/3ds%20Max),它可以将 Autodesk 的 3D Studio 产品导出为 Babylon.js 格式。

创建和转换 3D 模型是一个庞大的任务,以至于 WebGL 游戏开发很快就成为了开发者和设计师团队的项目,而非单个开发者的任务;然而,许多非常令人印象深刻的演示是完全由单人完成的,Babylon.js 网站上有一组很棒的展示。

WebGL 上下文的一个次要优势是,你可以使用它来渲染 2D 场景,这样就能利用 GPU 加速带来的巨大速度。粒子效果和在加速的 WebGL 中渲染大量屏幕元素的表现,远远超过了 canvas 中执行相同任务的效果。

我推荐你寻找能够在 WebGL 中启用 2D 渲染的现成库。一个这样的库是 Pixi.js (www.pixijs.com/),它还提供了对 canvas 的回退支持。

浏览器对 WebGL 的支持正在增长,包括最新版本的 Chrome、Firefox 和 Internet Explorer,尽管在写这篇文章时,旧版本的 Internet Explorer 不兼容。因此,WebGL 目前不被认为适合大规模市场开发,但这种情况正在持续改善。

创建一款精美的游戏固然很好,但没有玩家,游戏就没有意义。要让玩家访问,你需要将游戏部署到一个公开可访问的地方。根据部署的位置,你应该考虑做出一些改变,以提升玩家的体验。

部署 HTML5 游戏

在本节中,我将简要概述在桌面和移动浏览器中运行游戏的部署过程,并解释如何将 HTML5 应用程序封装为本地移动应用程序。

在桌面浏览器中运行全屏

部署 HTML5 游戏的一种方式是创建一个网站并上传它。事实上,只需将泡泡射手上传到 Web,就能让任何访问 index.html 文件的人都能玩这个游戏。将 HTML5 游戏部署到 Web 与部署任何其他网站没有什么不同;然而,玩家常常抱怨在浏览器中玩游戏时缺乏沉浸感,因为很容易被显示 Facebook、电子邮件、即时消息等通知的标签页所打扰。HTML5 提供了一个解决这些中断的技巧:全屏 API。

在支持的环境下,全屏 API 允许网页填满整个屏幕的宽度和高度,去除地址栏和其他浏览器框架元素。你可以通过运行以下 JavaScript 代码来实现全屏功能。出于安全原因,你需要在用户生成的事件处理程序内运行此代码;也就是说,通常你会为玩家创建一个按钮,或指定一个按键来激活全屏模式。

if(document.body.requestFullScreen) {
  document.body.requestFullScreen();
} else if(document.body.mozRequestFullScreen) {
  document.body.mozRequestFullScreen();
} else if(document.body.webkitRequestFullScreen) {
  document.body.webkitRequestFullScreen();
} else if(document.body.msRequestFullScreen){
  document.body.msRequestFullScreen();
}

请注意在实现 requestFullScreen API 时使用了供应商前缀(Firefox 使用 mozRequestFullScreen,Chrome 使用 webkitRequestFullScreen,等等)。当你调用 requestFullScreen 时,用户应该会看到浏览器弹出对话框,询问是否允许或拒绝游戏请求进入全屏。如果玩家允许全屏,按下 ESC 键应该会将他们返回到常规视图。

你还可以将全屏模式应用于 DOM 中的单个元素。如果你的网站中有运行中的游戏,并且包含导航到其他页面的功能,你可能想这样做。这样,玩家可以进入全屏模式,去除导航栏和其他页面杂物的干扰。你甚至可以将全屏模式应用于泡泡射手。只需添加一个新的工具栏按钮,当玩家点击该按钮时运行以下代码:

if(document.body.requestFullScreen) {
  $("#page").get(0).requestFullScreen();
}else if(document.body.mozRequestFullScreen) {
  $("#page").get(0).mozRequestFullScreen();
}else if(document.body.webkitRequestFullScreen) {
  $("#page").get(0).webkitRequestFullScreen();
}else if(document.body.msRequestFullScreen){
  $("#page").get(0).msRequestFullScreen();
}

我将把这部分留给你自己实现,并建议你将其添加到ui.js中,以便与其他用户界面代码一起管理。但如果你不想将其部署到你自己的网站,可以尝试使用托管服务。你可以在 Facebook 上设置一个应用程序,或者将游戏上传到专门的游戏网站,例如 Kongregate。

当然,跨平台开发和部署的承诺是 HTML5 最吸引人的特点之一,而且因为大多数桌面浏览器的功能已经移植到移动浏览器,泡泡射手应该能够在两者上都顺利运行。然而,不同平台之间的行为并不完全相同,接下来我将讨论这些差异。

在移动浏览器中运行

即使你仍然在本地或开发 Web 服务器上运行泡泡射手,你也应该能够在移动浏览器中加载并玩这个游戏。它的表现应该与在桌面浏览器上一样好。恭喜你,你刚刚制作了第一个移动游戏!

注意

如果你还没有发布游戏,你也可以在 buildanhtml5game.com/bubbleshooter/ 上玩它。

在为移动设备开发游戏时,你更有可能需要做的是可用性和界面方面的调整,而不是技术上的修改,但这并不意味着你可以完全忽视实现上的变化。你将从了解这些细微的行为差异和如何优化移动用户体验中受益,所以让我们开始吧。

触摸事件

首先,触摸屏设备上的浏览器实现了特定的触摸事件。这些事件中有两个是touchstarttouchend,它们大致等同于mousedownmouseup。然而,click事件在触摸屏环境中略有不同。移动浏览器会等待几百毫秒,以确定用户是否进行双击(即放大操作),以确保用户确实打算进行单次click。在泡泡射手中,这不会有太大区别,但在快速反应类游戏中,这几百毫秒的延迟对于玩家来说是可以察觉的。

你可以使用移动设备特有的事件,这些事件在没有触摸屏的桌面设备上会被忽略,尽管大多数情况下,使用mousedown将与touchstart效果相同,mouseup则等同于touchend。例如,在泡泡射手中,我们可以使用mousedown代替click来检测玩家何时想要发射泡泡,这样会将game.js中的这一行变成:

$("#game").bind("click",clickGameScreen);

改为以下这行代码:

$("#game").bind("mousedown",clickGameScreen);

唯一的效果是,玩家点击鼠标按钮或触摸屏幕时,泡泡会立即发射,而不是等待鼠标按钮释放或手指离开屏幕。

注意

如果只使用鼠标和触摸事件,将会移除键盘的可访问性,特别是如果你的游戏本来可以用键盘控制的话。在某些游戏中,你可能仍然希望使用点击事件,以便玩家仍然可以使用键盘或其他输入设备导航菜单系统。

如果你知道你的游戏只会在移动设备上玩,你也可以使用touchstart

$("#game").bind("touchstart",clickGameScreen);

这应该与mousedown的效果相同。

那么你可能会想,既然 touchstarttouchendmousedownmouseup 几乎等价,那它们存在的意义是什么?答案是,在大多数情况下,你可以将它们视为概念上的等价物,但触摸事件在你需要同时检测多个触摸点时非常有用。用户通常只有一个鼠标指针,但在触摸屏上,可以在多个位置进行触碰。如果你正在构建一个需要这种输入的游戏,触摸事件是你要使用的,而你也需要找到方法在鼠标环境中使它们生效。

缩放

另一个交互差异出现在缩放操作上。你可能不希望玩家在游戏区域进行缩放,无论他们是否双击。幸运的是,你可以通过向 HTML 头部添加 <meta> 标签来限制这一点:

<meta name="viewport" content="user-scalable=no, initial-scale=1,
maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />

这个示例告诉浏览器将页面按 1:1 的比例渲染,并将视口宽度设置为设备的默认值。<meta> 标签的内容指定了显示的大小,并限制(或允许)缩放。这个 <meta> 标签最初是由 Apple 引入的,其他浏览器也以它为基础实现自己的行为。因此,Apple 自己的文档 (developer.apple.com/library/ios/documentation/AppleApplications/Reference/SafariWebContent/UsingtheViewport/UsingtheViewport.html) 是查看各种选项描述的最佳地方。然而,使用这个标签实际上就是查找任何特定移动浏览器的预期行为,然后测试它在实际中的效果。目前,CSS 正在进行视口大小的标准化工作 (www.w3.org/TR/css-device-adapt/),但目前浏览器支持有限。

<meta> 标签中,你最常用的选项是 user-scalable=no,它简单地防止用户进行缩放。但改变 <meta> 标签中的其他值也会极大地影响浏览器如何显示你的游戏。<meta> 标签中的设置如下:

  • user-scalable。可以是 yes 或 no。允许或禁用缩放。

  • initial-scale。一个小数,指定绘制视口时的缩放因子。

  • maximum-scale。一个表示允许用户缩放到的最大缩放比例的小数。

  • minimum-scale。一个表示允许用户缩放到的最小缩放比例的小数。

  • width。指定为像素值,或者使用 device-width

  • height。指定为像素值,或者使用 device-height

如果游戏的设计宽度为 760 像素,例如,你可以将width设置为 760,浏览器将保持该宽度,并去除两侧多余的空白像素。不幸的是,通过缩放视口,你几乎肯定会遇到图像缩放和纵横比的问题;尝试在 1024 像素的屏幕上绘制 760 像素意味着需要进行一些锯齿化处理。

移动设备之间的纵横比变化比桌面屏幕要大得多。例如,iPad 1 和 2 的分辨率是 1024×768,iPad 3 是 2048×1536,iPhone 6 是 750×1334,iPhone 6 Plus 是 1080×1920,而 Android 设备的分辨率几乎与设备数量一样多。不幸的是,没有简单的解决方案。确保在各种设备上不断测试,并尝试结合使用<meta>属性和 CSS 布局,以确保你的游戏在不同的屏幕尺寸和纵横比上都能良好显示。

当然,即使你解决了纵横比问题,如果用户仍通过手机浏览器玩你的游戏,他们可能无法在离线状态下玩游戏。要真正将 HTML5 游戏部署到设备上,你需要将代码打包成原生应用程序。当你的游戏是原生应用时,用户应该能够在线或离线玩游戏,除非你的游戏本身就需要互联网连接。接下来我们来看看如何使用封装器服务。

部署为原生应用程序

你有两种主要方式将 HTML5 游戏部署为原生 Web 应用程序。你可以使用 Objective-C、Java 或目标平台要求的其他语言编写封装器,或者使用现有的封装器服务。除非你对原生移动编码非常熟练,否则我强烈建议你使用封装器服务。

封装器服务,如 PhoneGap/Cordova (cordova.apache.org/ ) 和 Ludei (www.ludei.com/ ),虽然提供的控制较少,但通常可以访问原生功能,如加速计和应用内购买。有时它们甚至提供加速的图形功能和定制的 API。它们也需要更少的时间和精力,是构建测试部署的绝佳方式,这样你可以快速看到游戏在设备上运行的效果。除非你有非常充分的理由,否则我建议使用这些服务。

使用第三方封装器通常涉及通过在线服务上传你的 HTML5 代码,并为每个设备下载编译后的版本。这些服务实际上完成了与自定义封装器相同的工作,但经过多次迭代优化,通常支持多个平台。它们还不断为更新的手机和操作系统添加支持,而这些内容如果自己跟进是非常耗时的。此外,社区通常会编写插件提供额外的功能,例如提供应用内购买或访问设备的相机。

只需记住,无论你决定如何包装你的 HTML5 应用程序,文件都会在本地环境中运行;也就是说,你的游戏不需要通过网络或服务器下载资源。因此,即使没有网络连接,你的游戏仍然可以运行。如果你正在开发一款多人游戏,它需要活跃的互联网连接,但即便如此,你的游戏也将受益于更快的启动时间,并且(如果你的游戏很受欢迎)还能节省带宽费用。像往常一样,进行持续的迭代测试,以便在问题变成重大问题之前就加以发现。

这就是我关于移动设备的部分内容,但在桌面浏览器上,Bubble Shooter 足够简单,除非你在非常低配的机器上玩,否则你应该不会遇到性能问题。但在某个阶段,随着你开发更复杂的游戏,你会发现某些代码比预期运行得更慢,届时你就需要优化这部分代码。

优化

当你在优化游戏时,主要要关注的两个方面是内存管理和速度。特别是,你应该确保游戏在运行时间越长时,不会消耗越来越多的系统资源,同时你还需要充分利用现有的硬件和编程技巧来提升速度。

无论你是否遇到明显的问题,比如动画变慢,定期检查游戏的性能都是一个好习惯。通常,只有在遇到特定问题时,你才需要进行速度优化,但无论如何,保持对内存使用情况的关注始终是一个好习惯。例如,一个游戏在你玩五分钟时可能运行得很顺利,但如果你让它在浏览器标签页中保持开着几个小时,你可能会发现回到游戏时,漂亮的动画循环因为内存泄漏已经消耗了几十或几百兆字节的内存。对于较弱的移动设备来说,这可能是一个真正的问题。

幸运的是,浏览器工具可以帮助识别和诊断问题,你也可以实现一些编码技巧来修复这些问题或尽量减少问题发生的风险。良好的内存管理尤为重要,因此在我们继续进行速度优化之前,我们先来看一下这方面的内容。

内存管理

你可能不会期望一个小型的 JavaScript 游戏会在能够顺利运行大型 3D 游戏的系统上遇到内存问题,但内存管理实际上是 HTML5 游戏开发者面临的一个紧迫问题。问题不在于内存用完(尽管通过一些努力,确实有可能耗尽大量内存),而在于 JavaScript 分配和释放内存的方式。浏览器不会不断地分配和释放内存,而是定期进行清理操作,这可能会导致动画卡顿、界面无响应以及游戏流程中断等问题。

以节省内存的方式编写 JavaScript 是一个庞大的话题,浏览器厂商经常发布关于如何充分利用其系统的论文。例如,查看 Mozilla 关于内存管理的文档,链接为 developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management/。你还可以阅读由 Chrome 工程师 Addy Osmani 编写的关于内存高效 JavaScript 的优秀介绍,链接为 www.smashingmagazine.com/2012/11/05/writing-fast-memory-efficient-javascript/

处理内存问题的关键是首先识别问题。你可能怀疑自己遇到了问题,但你需要知道问题出在哪里。主要的桌面浏览器都有帮助的工具。这些工具在不断发展,所以我不会深入讨论它们。但通过浏览每个浏览器的文档,应该能找到相关的文档和教程,比如 Chrome 的文档,链接为 developer.chrome.com/devtools/docs/javascript-memory-profiling/

以下是三大浏览器中的操作步骤:

  • 在 Chrome 中,打开开发者工具并点击Profiles。选择Take Heap Snapshot,然后点击Take Snapshot以检查内存中的对象,包括 DOM 元素。图 8-1 展示了 Bubble Shooter 的内存快照。

  • 在 Firefox 中,你可以使用 Firebug 和其他插件来检查内存中的对象。你也可以在地址栏输入 about:memory 来查看当前浏览器内存的快照。

  • 在 Internet Explorer 11 中,打开开发者工具并选择Memory工具。

通过 Chrome 浏览器工具显示的 Bubble Shooter 内存快照

图 8-1. 通过 Chrome 浏览器工具显示的 Bubble Shooter 内存快照

另一个有用的工具是可视化垃圾回收发生的时刻。它以一条时间轴上的图形形式呈现,你可以看到你的游戏占用了多少内存。图 8-2 展示了 Bubble Shooter 随时间的内存使用情况。

锯齿形的线代表在创建对象时使用的内存。当对象被创建时,线条上升,而在垃圾回收发生时,线条会急剧下降。尽管我们没有创建和销毁许多对象,但如果我们看到动画运行不流畅的问题,明显的迹象是我们可以考虑使用更多的对象池。

保持快速动画的关键是进行测试和迭代。这对于移动设备开发尤其重要,因为移动设备上的调试工具通常更难访问,而且内存和处理能力通常也更有限。如果你注意到间歇性的减速和动画冻结,且这些问题难以重现,那很可能是你遇到了内存问题,需要进行排查和解决。

Bubble Shooter 的内存使用情况

图 8-2. Bubble Shooter 的内存使用情况

优化速度

内存是否成为问题,取决于你游戏的需求,而内存优化有时需要使用一些与编写可读、可复用代码相冲突的编码技巧。然而,通过遵循一般的最佳实践,优化速度通常会作为副作用自然实现。

JavaScript 引擎的速度一直在不断提升,浏览器的渲染引擎也是如此(尤其是在添加了 WebGL 之后)。但是,与垃圾回收一样,你仍然需要意识到性能瓶颈。浏览器厂商不会为你解决所有的性能问题。实际上,JavaScript 解释器变得如此快速,以至于性能问题更可能出现在渲染过程中,而不是其他地方;然而,编码技巧可以使 JavaScript 与机器码之间的转换更高效,从而加速操作,例如将图像数据传递给渲染引擎。

每次你在 DOM 中添加或更改一个元素时,浏览器都需要计算出需要绘制什么内容以及绘制的位置。HTML 文档最初是作为流式的、基于文本的文档设计的,浏览器会假设你发送给它的内容应该像任何其他网页一样进行布局。

但是,导致浏览器重新绘制显示的操作,如向屏幕添加新元素或更改元素的坐标,在游戏中是非常常见的。在 Bubble Shooter 中,我们可以随意添加和移除元素,因为屏幕上的元素相对较少。当屏幕上的元素数量增加 10 倍或 100 倍时,你会开始看到问题。记住,垃圾回收器需要清除任何从场景中删除的元素,而 DOM 元素通常是复杂的。

相比之下,canvas 元素在处理图形添加时没有昂贵的绘制操作,因为 canvas 内部不会发生重排。浏览器将 canvas 元素视为图像,这些图像只是从内存到屏幕的像素流。

注意

更改 canvas 元素的属性,例如其位置或透明度,而不是其内部的像素,与更改其他 DOM 元素的代价相同。

你可以通过在 Chrome 桌面浏览器中加载 Bubble Shooter,按 F12 打开开发者工具,然后导航到时间轴标签页,来查看浏览器绘制场景所花费的时间。点击底部控制栏中的 记录,重新加载游戏,然后点击顶部的 事件 栏,查看类似图 8-3 的视图。

在 Chrome 中玩 Bubble Shooter 的浏览器事件

图 8-3. 在 Chrome 中玩 Bubble Shooter 时涉及的浏览器事件

所有的绘制事件 ➊,例如图 8-3 中的那些,应该在屏幕上用绿色突出显示。在游戏的画布版本中,只有在加载完关卡后才会发生几次绘制调用,而在 CSS 版本中,这些调用会不断发生。

你可以使用时间轴工具来识别绘制事件发生的时刻,并将其最小化以加速游戏的渲染。只需要记住,不同浏览器可能在不同的时间重新绘制场景。像往常一样,使用可用的工具,但也要在目标平台和设备上进行测试,将其作为性能的主要指导。

一般来说,最小化 DOM 操作是减少绘制操作的关键。查找有关最小化浏览器重排和浏览器绘制操作的文章,获取有关渲染引擎内部工作原理的更详细和最新的信息。

安全性

如果你的游戏有任何类型的得分或进度系统,某人就会尝试作弊。但关键是评估作弊被系统突破的后果,并决定这些后果是否严重。对于 Bubble Shooter 来说,这不是问题:如果有人想在他们本地的机器上设置一个高分,那是他们的事。然而,对于有在线竞争元素或购买增强道具作为收入来源的游戏,你需要确保作弊变得极其困难甚至不可能。

我们可以通过几种方式来解决 HTML5 游戏中的安全问题。

不信任任何人

任何在客户端运行的游戏,无论是使用 HTML5、Flash 还是原生代码构建的,其简化的安全处理方式就是不信任客户端发送给服务器的任何数据。例如,使用 AJAX 和 Web-Sockets 示例中的高分值进行 POST 请求时,很容易被伪造。分数可能是有效的,但 POST 请求可能是伪造的,或者某人甚至可能使用调试工具在游戏运行时更改高分。服务器只看到接收到的数据,无法区分真实的请求和作弊请求。

不幸的是,不信任客户端通常是正确的方法:没有办法完全保证在客户端运行的代码的安全性。确保游戏安全的唯一方法是让所有游戏逻辑都由服务器处理。为了完全保护Bubble Shooter,我们将鼠标点击传递给服务器,让碰撞和爆炸逻辑在服务器上运行,然后将结果传回客户端进行动画处理。这种方法开发和测试起来更为困难,并且用户需要一个持续(且快速)的互联网连接才能玩游戏。

混淆

当游戏包含金融交易时,服务器端方法是必不可少的,但对于许多游戏来说,混淆已经足够好。混淆的理念是使作弊变得尽可能困难,基本上是让付出的努力超过得到的回报。例如,如果一个高分被作为编码值传送到服务器,附带一个校验和,并且需要花费数小时阅读代码才能破译它是如何生成的,作弊者不太可能付出所有的努力,仅仅是为了登上高分榜的顶部。

当然,混淆通常会以牺牲可读性为代价,既对你,也对黑客来说。但有很多方法可以让代码变得难以阅读,你甚至可以在构建后处理过程中应用其中一些方法。

最简单的选项是在你将代码打包到生产环境之前,使用一个压缩工具来处理代码。压缩工具会缩短代码中所有长变量名并去除空白符。例如,像这样的代码:

var highScore = 0;
highScore += 20;

压缩后变成类似这样的:

var highScore=0;highScore+=20;

本质上,压缩会去除空白符并将所有内容放到一行上。压缩后的代码很快变得难以阅读。你可以轻松地将行 breaks 还原。许多压缩工具还会重命名函数内部的变量。例如,这个函数:

var highScore = (function(){
  var highScore = 0;
  highScore += 20;
  return highScore;
});

可能会变得更小:

var highScore=function(){var a=0;return a+=20};

如果你在代码中一直调用的 highScore 属性现在被叫做 a,那么它将变得更难以找到!

注意

压缩代码的额外好处是生成更小的代码,因此可以更快加载,这是在 Web 环境中部署时需要考虑的一个重要因素。实际上,你应该在所有 Web 应用程序中考虑压缩你的 JavaScript 代码。

Google 发布了一个名为 Closure Compiler 的工具,它不仅作为一个压缩器,还提供了许多其他好处。它会尝试优化代码,甚至在某些地方重写代码,并输出比原始版本更小,有时甚至更快的代码。该编译器生成 JavaScript,分析你的代码并抛出错误。在使用像 Closure Compiler 这样的压缩工具时,声明变量、跟踪作用域并保持其他良好实践会有所回报,因为代码结构越清晰简洁,编译器提供的好处就越大。

你可以在线使用 Closure Compiler,或者下载并运行来自developers.google.com/closure/compiler/的 Java 应用程序。一旦你能够访问它,粘贴你想要编译的 JavaScript 代码,然后复制输出结果。建议你保留原始代码的副本,因为如果你需要进行进一步修改,编译器输出将非常难以处理。

使用私有变量

除了构建后处理之外,你还可以以更难让作弊者跟踪代码并即时更改它的方式进行编码。例如,私有变量使得在控制台上操纵内部值变得更加困难。以下代码使用了一个私有的highScore变量:

var Game = function(){
  var highScore = 0;
  var getHighScore = function(){ return highScore;};
  return this;
};

该变量被视为私有的,因为它只存在于Game对象的作用域内。我们本可以将该变量公开,如下所示:

var Game = function(){
  this.highScore = 0;
  var getHighScore = function(){ return this.highScore;};
  return this;
};

这样就可以通过仅更改highScore属性的值来改变Game对象中highScore的值。然而,在私有版本中,无法从外部访问highScore的值。

如果highScore是私有的,作弊者将很难在不使用 Firebug 等程序添加断点的情况下更改其值。如果代码被压缩和混淆,他们就会更难做到。highScore实际上被标记为"a",而且很难找到第一次更新高分的地方。

通过几个相对简单的步骤(使一些变量私有并压缩我们的代码),我们已经将潜在的作弊者范围缩小到那些仅懂一些 JavaScript 的玩家与那些对 JavaScript 非常熟悉并愿意花时间逆向工程我们的代码的人之间。现在,让我们再看一种防止作弊的方法。

验证校验和

你还可以通过使用校验和来验证传递给服务器的变量,从而保护信息。最简单的技术是对值进行编码,以便至少进行某些检查,确保数字是正确的。这不会消除作弊,校验和也不需要太复杂,但它会确保任何想作弊的人都需要先阅读并理解你的 JavaScript 代码。例如,如果我们将highScore传递给服务器,我们可能会 POST 类似这样的内容:

{
  highScore : 9825,
  check : 21
}

数值 21 是 9,825 对 129 取模的结果(或在代码中表示为highScore%129),其中 129 是我选择的一个数字,足够大以创建一系列校验值,同时也小于可能的高分。这种几乎微不足道的检查实际上增加了安全性,因为现在发布虚假高分的障碍不仅是知道如何发布,还需要能够追踪代码,直到check值被创建。一个经验丰富的 JavaScript 程序员可能会觉得这些步骤很简单,但普通的游戏玩家可能就不那么容易了。

前面的例子可能对你来说过于简单,你可以使用任何你喜欢的过程来生成校验和。常见的方法包括使用哈希函数,如 MD5、SHA 或 CRC32,尽管这些方法的缺点是,程序员通常能很好地识别其结构,知道他们正在查看的是标准的哈希函数。

原则上,你创建的任何可以生成一系列校验值的过程都会显著减慢速度,并可能劝阻大量潜在的作弊者。

当然,不管你做什么,仍然可能会遇到一些作弊者,因为有些黑客喜欢击败程序员的挑战,胜过击败游戏的挑战。你可以尽可能地混淆代码,可能最终会得到几乎无法阅读的代码。但请记住,你永远无法保证客户端代码的安全性,也永远不要完全相信从客户端传递到服务器的信息。

总结

正如你从本章中可能已经了解到的那样,浏览器对 HTML5 的支持是一个不断变化的领域。好消息是,浏览器通常趋向于统一标准,而不是增加自己的功能。同时,HTML5 的支持也在不断改善。

随着变化速度的加快,保持对哪些浏览器功能已准备好进入主流使用以及未来可能出现的新功能的关注非常重要。无论是性能提升、内存管理、声音,还是 3D 功能,HTML5 游戏的能力在不断进步。

进一步练习

  1. 在桌面浏览器中为Bubble Shooter添加全屏功能。为了让切换尽可能简单,添加一个按钮到顶部栏,该按钮仅在支持全屏模式时才会显示。此外,修改 CSS,使得页面在全屏显示时,游戏居中。

  2. 编写一个例程,使用 jQuery 的ajax方法将玩家的分数发布到一个虚拟服务器地址。在每一关结束时发布分数,并编写一个校验和函数,使用你选择的方法增加基本的安全性。

  3. 查找并测试一些在线的压缩和混淆服务。比较输出代码的文件大小与原始源代码的大小。

附录 A. 后记

希望通过本书的学习,你已经了解了利用 HTML5 和 JavaScript 开发游戏的简单性,并且对这些技术的潜力有了一定的认识。那么,接下来的问题是:接下来该做什么?答案是:去制作更多游戏吧!

利用你新掌握的技能和互联网上的 HTML5、CSS、JavaScript 参考资料,你应该能应对几乎所有基于 HTML5 的游戏,尽管我建议你下一个项目要尽量小巧且可实现。大多数开发者未完成的项目清单远长于已完成的项目清单,因此从一个能够在“已完成”栏打上勾的游戏开始吧。

如果你已经有了一些游戏创意,并且认为自己可以实现它们,尽管大胆去做吧!如果你不确定从哪里开始,以下是一些建议,帮助你提升技能并打造个人作品集。

改进气泡射手

气泡射手本身已经很不错了,但我们都知道它还可以更好。任何游戏都永远有改进的空间!以下是一些建议:

  • 增加一些在气泡被击破时掉落的能量提升和奖励点数,用户需要点击来收集。

  • 在后期关卡中增加更多气泡颜色。

  • 创建不同大小和布局的网格模式。

  • 实现侧墙,让玩家可以将发射的气泡反弹到墙壁上。

你不需要重新编写一个全新的游戏来加入这些功能,既然气泡射手的大部分内容已经就绪,你可以专注于精细化这些功能。加入一些自己的创意,你就能做出让人停不下来的游戏!

创建全新游戏

你可以通过花时间打磨像气泡射手这样的游戏学到很多东西,但要建立作为游戏开发者的信心,没有什么比尽可能多地开发游戏更有用的了。你可以自己创造全新的游戏创意,或者为了加快编程过程,选择一些现有的游戏并尝试理解它们是如何制作的。我会描述几个基本的游戏创意,你可以利用新学的技能来实现它们。

三消游戏

三消游戏,例如宝石迷阵糖果传奇,似乎从未过时,它们既带来了明确的技术挑战,也需要精致的用户界面设计。考虑一下宝石爆炸和掉落的过程,这会导致更多宝石掉落和爆炸,如此循环。设想一下如何设计算法来处理这些连锁反应,并考虑如果用户在这一切发生时试图交换宝石,会发生什么情况。你会允许玩家这么做吗?尝试构建最好的此类游戏,确保它能正常运行,然后,一旦你完成了,去玩宝石迷阵或其他流行版本,找出你认为使游戏体验有趣的特点,尽量给你的游戏加入类似的润色。细腻却有效的设计细节真的能带来巨大的不同。

扑克牌

卡牌游戏在图形表现上比其他类型的游戏简单,但它们带来了足够的用户界面和游戏逻辑挑战,因此值得投入精力去开发。游戏逻辑一旦建立,你就可以为玩家提供定制的卡组背面和动画效果,以赋予你的游戏个性。务必混淆代码,以防玩家在游戏过程中窥探到卡组状态!

平台游戏

平台游戏是比前面提到的游戏类型更高一层的挑战。你需要为主角实现一些基本的物理效果(尽管我不建议你为整个游戏实现真实物理模拟),并实现某种形式的滚动,可能是仅仅水平滚动,也有可能是双向滚动。关卡设计可以保持简单:定义一个入口点和一个出口点,让玩家穿越这两点。最终,你将开始更多地考虑如何为未来的游戏复用代码,并且你会解决诸如动画化移动人物等问题。

一个简单的物理游戏

愤怒的小鸟是一款巨大的成功,这也让人更加惊讶于其基本机制竟如此简单便于重现。愤怒的小鸟使用了一款名为 Box2D 的物理引擎,并且有一个免费的 JavaScript 版本,叫做 Box2dWeb。你可以在https://code.google.com/p/box2dweb/](https://code.google.com/p/box2dweb/)找到代码和文档。你在网上找到的示例并不总是容易理解,并且将物理引擎加入游戏也是具有挑战性的。我推荐 Seth Ladd 的教程,它为你提供了逐步介绍这款库的指南,教程地址是http://blog.sethladd.com/2011/08/box2d-orientation-for-javascript.html](http://blog.sethladd.com/2011/08/box2d-orientation-for-javascript.html)

加入游戏开发团队

如果到目前为止提到的任何想法都无法激发你的创意,或者你正为自己的游戏创意苦恼,考虑找一个正在寻找合作伙伴的游戏设计师,帮助实现他们的创意。像 Meetup 这样的站点(http://meetup.com/](http://meetup.com/))是寻找游戏开发团队的好地方。在那里,你可以遇到并可能与既有经验的开发者和有抱负的游戏开发者合作。

借助 HTML5,个人或小团队比以往任何时候都更容易创建可以在桌面和移动设备上供大众玩耍的游戏。抓住这个机会——去创造游戏吧!

附录 B. 更新

访问 nostarch.com/html5game/ 获取更新、勘误和其他信息。

更多实用书籍来自 NO STARCH PRESS

image with no caption

Eloquent JavaScript,第 2 版

现代编程入门

MARIJN HAVERBEKE 编写

2014 年 12 月,472 页,$39.95

ISBN 978-1-59327-584-6

image with no caption

儿童 JavaScript 教程

编程入门的趣味指南

NICK MORGAN 编写

2014 年 12 月,336 页,$34.95

ISBN 978-1-59327-408-5

全彩版

image with no caption

如果海明威写 JavaScript

ANGUS CROLL 编写

2014 年 10 月,192 页,$19.95

ISBN 978-1-59327-585-3

image with no caption

CSS3 手册

开发者的 Web 设计未来指南

PETER GASSTON 编写

2014 年 11 月,304 页,$34.95

ISBN 978-1-59327-580-8

image with no caption

RAILS 快速入门

Rails 开发实用指南

ANTHONY LEWIS 编写

2014 年 10 月,296 页,$34.95

ISBN 978-1-59327-572-3

image with no caption

构建你自己的网站

HTML、CSS 和 WordPress 漫画指南

NATE COOPER 编写,插图由 KIM GEE 提供

2014 年 9 月,264 页,$19.95

ISBN 978-1-59327-522-8

电话:

800.420.7240 或 415.863.9900

电子邮件:

SALES@NOSTARCH.COM

网站:

WWW.NOSTARCH.COM

posted @ 2025-11-26 09:17  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报