jQuery-游戏开发基础-全-

jQuery 游戏开发基础(全)

原文:zh.annas-archive.org/md5/7D66632184130FBF91F62E87E7F01A36

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

编写游戏不仅有趣,而且是通过透彻地学习一项技术的非常好的方法。尽管 HTML 和 JavaScript 并不是为了运行游戏而设计的,但在过去的几年中,一系列事件发生,使得用 JavaScript 编写游戏成为可行的解决方案:

  • 浏览器的 JavaScript 引擎性能有了显著提高,现代引擎比 2008 年的最先进引擎快了十倍。

  • jQuery 和其他类似的库使得与 DOM 的操作尽可能轻松。

  • Flash 在某种程度上由于在 iOS 上的缺失而失去了很多地位。

  • W3C 开始了许多面向游戏的 API 的工作,如 canvas、WebGL 和全屏 API。

在整本书中,你将制作三款游戏并学习各种技术。你不仅可以使用自己的游戏,更重要的是你将在过程中获得乐趣!

本书内容

第一章, 游戏中的 jQuery,深入探讨了对游戏开发可能有用的 jQuery 函数。

第二章, 创建我们的第一个游戏,使用精灵、动画和预加载实现了一个简单的游戏。

第三章, 更好、更快、但不更难,通过各种技术如超时内联、键盘轮询和 HTML 片段优化了我们在第二章中看到的游戏创建我们的第一个游戏

第四章, 横向看,用瓷砖地图和碰撞检测编写了一个平台游戏。

第五章, 物以类聚,创建了一个正交 RPG 游戏,采用了瓦片地图优化、精灵遮挡和更好的碰撞检测。

第六章, 给你的游戏添加关卡,通过使用 JSON 和 AJAX 添加多个关卡,扩展了我们在第四章中看到的游戏横向看

第七章, 制作多人游戏,将我们在第五章中看到的游戏转变为支持多台机器上的多个玩家。

第八章, 让我们变得社交化,将平台游戏与 Facebook 和 Twitter 集成,并创建一个防作弊的排行榜。

第九章, 让你的游戏移动起来,将我们在第五章中看到的游戏优化为适用于移动设备和触摸控制。

第十章, 制造一些声音,通过音频元素、Web Audio API 或 Flash,为你的游戏添加音效和音乐。

本书的需要

使用 web 技术的一个优势是,你无需任何复杂或昂贵的软件即可开始。对于纯粹的客户端游戏,你只需要你最喜欢的代码编辑器(甚至是一个简单的文本编辑器,如果你不介意在没有任何语法高亮的情况下工作)。如果你还没有选择的话,那么你可以试试周围的免费软件,从非常老式的软件,比如 VIM(www.vim.org/)和 Emacs(www.gnu.org/software/emacs/)到更现代的软件,比如 Eclipse(www.eclipse.org/)和 Aptana(www.aptana.com/),Notepad++(notepad-plus-plus.org/),或者 Komodo Edit(www.activestate.com/komodo-edit)。这些只是你可以找到的一些可用编辑器。对于 JavaScript,你不需要一个非常先进的编辑器,所以只需使用你更熟悉的那个。

如果你创建自己的图形,你还需要一款图像编辑软件。在这方面,你会有很多选择。最著名的开源软件是 Gimp(www.gimp.org/),还有我个人最喜欢的 Pixen(pixenapp.com/)。

对于书中需要一些服务器端脚本的部分,我们将会使用 PHP 和 MySQL。如果你还没有支持它们的服务器,你可以在你的机器上安装它们,你可以使用 MAMP(www.mamp.info/)、XAMPP(www.apachefriends.org/en/xampp.html),或者 EasyPHP(www.easyphp.org/),具体根据你的操作系统来选择。

本书适合人群

本书的主要观众是有一些 JavaScript 和 jQuery 经验的初学者 web 开发人员。由于服务器端部分是用 PHP 实现的,如果你对 PHP 也有一些了解的话会有所帮助,但是如果你更喜欢其他服务器端语言的话,你也可以使用其他语言代替 PHP 而不会有太多麻烦。

你完全不需要任何游戏开发的先验知识就能享受本书!

约定

在这本书中,你会发现许多不同种类的信息的文本样式。以下是其中一些样式的例子,以及它们的含义解释。

文本中的代码单词显示如下: "jQuery 的.animate()函数允许你让一个属性从当前值按照时间变化到一个新值。"

一个代码块设置如下:

$("#myElementId")
.animate({top: 200})
.animate({left: 200})
.dequeue();

当我们想吸引你的注意到代码块的特定部分时,相关的行或项用粗体表示:

gf.keyboard = [];
// keyboard state handler
 $(document).keydown(function(event){
 gf.keyboard[event.keyCode] = true;
});
$(document).keyup(function(event){
    gf.keyboard[event.keyCode] = false;
});

任何命令行输入或输出都如下所示:

# cp /usr/src/asterisk-addons/configs/cdr_mysql.conf.sample
     /etc/asterisk/cdr_mysql.conf

新术语重要词汇以粗体显示。例如,在屏幕上看到的单词,在菜单或对话框中出现的单词,将以这样的方式出现在文本中:"下图显示了两个段 ab 的一维交集 i 的典型情况"。

注意

警告或重要提示将以这样的方式出现在一个框中。

提示

提示和技巧会以这样的方式出现。

第一章 jQuery 游戏编程

在过去的几年里,jQuery 几乎已经成为任何 JavaScript 开发的默认框架。超过 55%的最受欢迎的 10,000 个网站以及估计总共 2400 万个网站正在使用它(更多信息,请参阅trends.builtwith.com/javascript/JQuery)。而且这一趋势并没有显示出任何停止的迹象。

本书期望您具有一些 jQuery 的相关经验。如果您觉得自己不符合这个要求,那么您可以首先在学习 jQueryJonathan ChafferKarl SwedbergPackt Publishing中了解更多。

本章将快速浏览 jQuery 的特点,然后更深入地了解它最具游戏性的函数。即使您可能已经使用过其中的大部分,您可能还不了解它们的全部功能。以下是本章涵盖的主题的详细列表:

  • jQuery 的特点

  • 将帮助您移动元素的函数

  • 事件处理

  • DOM 操作

jQuery 的方式

jQuery 的哲学与大多数之前的 JavaScript 框架有所不同。了解它使用的设计模式是编写可读和高效代码的关键。我们将在接下来的几节中讨论这些模式。

链接

大多数 jQuery 语句的形式如下:选择后跟一个或多个操作。这些操作的组合方式被称为链式,并且是 jQuery 最优雅的方面之一。一个使用 jQuery 的初学者想要将元素的宽度设置为 300 像素,高度设置为 100 像素,通常会写成:

$("#myElementId").width(300);
$("#myElementId").height(100);

使用链接,这可以写成:

$("#myElementId").width(300).height(100);

这有很多优点:元素只被选择一次,并且生成的代码更紧凑,传达了语义意义,即你想要实现的确实只是一件事,那就是改变元素的大小。

允许链式调用的函数不仅可以将许多调用组合在同一个对象上,还有许多实际上可以在哪个对象(或对象)上进行下一个链上的函数操作的方法。在这些情况下,通常使用缩进来传达这样一个想法:你不是在同一层级的元素上操作。

例如,以下链首先选择一个元素,然后将其背景颜色设置为red。然后将链中的元素更改为前一个元素的子元素,并将它们的background-color属性更改为yellow

$("#myElementId").css("background-color", "red")
   .children().css("background-color", "yellow");

很重要的一点是,您必须始终问自己当前和链中上一个和下一个元素的相互作用如何可以避免产生不良行为。

多态性

jQuery 有自己的多态使用方式,给定函数可以以许多不同的方式调用,具体取决于你想给它多少信息。让我们看一下.css()函数。如果只使用String数据类型作为唯一参数调用该函数,则该函数将作为 getter 运行,返回你要求的 CSS 属性的值。

例如,以下行检索给定元素的左侧位置(假设它是绝对定位的):

var elementLeft = $("#myElementId").css("left");

但是,如果传递第二个参数,则它将开始行为类似于 setter,并设置 CSS 属性的值。有趣的是,第二个参数也可以是一个函数。在这种情况下,函数预计返回将设置为 CSS 属性的值。

以下代码就是这样做的,并使用一个函数,该函数将增加元素的左侧位置一个单位:

$("#myElementId").css("left", function(index, value){
   return parseInt(value)+1;
});

但是;等一下,还有更多!如果你向同一个函数只传递一个元素,但该元素是一个对象文字,那么它将被视为保存属性/值映射。这将允许你在一个单一调用中更改许多 CSS 属性,就像在以下示例中将左侧和顶部位置设置为 100 像素一样:

$("#myElementId").css({
   left: 100,
   top: 100
});

你也可以像在 JSON 中那样,使用字符串作为对象文字的键和值。

一个非常完整的资源,用于了解调用函数的所有方式,是 jQuery API 网站 (api.jquery.com)。

现在我们将重点关注一些对开发游戏感兴趣的函数。

移动物体

对于动画,链式有着稍微不同的意义。虽然你在大多数游戏中实际上可能从未需要使用 jQuery 动画函数,但仍然有意思看到它们的工作特点,因为它可能导致许多奇怪的行为。

链接动画

jQuery 的.animate()函数允许你通过时间使属性的值从当前值变化到新值。举个典型的例子,可以移动它左边 10 像素,或者改变它的高度。从你之前看到的以及体验到其他类型的函数,你可能期望以下代码将使一个 div(DOM division 元素)对角线移动到位置left = 200pxtop = 200px

$("#myElementId").animate({top: 200}).animate({left: 200});

然而,它并不会!相反,你将看到 div 首先移动到达top = 200px,然后才移动到left = 200px。这称为排队;每次调用animate都将排队到之前的调用,并且只有在它们都完成后才会执行。如果你想同时执行两个移动,从而生成对角线移动,你将只能使用一次.animate()调用。

$("#myElementId").animate({top: 200,left: 200});

另一种可能性是明确告诉.animate()函数不要排队执行动画:

$("#myElementId").animate({top: 200}).animate({left: 200},{queue: false});

请记住,这也适用于实际上是包装在.animate()函数周围的其他函数,例如以下情况:

  • fadeIn()fadeOut()fadeTo()

  • hide()show()

  • slideUp()slideDown()

链接动画

管理队列

下面是一系列函数,你可以使用它们来操作这个动画队列。

.stop()

.stop() 函数停止队列当前的动画。如果你在调用时提供了更多的参数,你还可以清除队列并定义元素是否停止动画并停留在原地,或者跳转到它们的目标位置。

.clearQueue()

.clearQueue() 函数从队列中删除所有动画;不仅是当前的动画,还有所有接下来的动画。

.dequeue()

.dequeue() 函数启动队列中的下一个动画。这意味着当调用此函数时正在执行动画时,新的动画将在当前动画执行完成后开始。例如,如果我们拿本节开头的示例并在结尾添加一个 dequeue() 函数,元素将实际上开始对角线移动。

$("#myElementId")
.animate({top: 200})
.animate({left: 200})
.dequeue();

.delay()

.delay() 函数允许你在队列中的两个动画之间插入一个暂停。例如,如果你想要使用 .fadeIn() 使元素可见,然后等待 2 秒,再用 .fadeOut() 使其消失。这将被写成这样:

$("#myElementId").fadeIn().delay(2000).fadeOut();

队列的其他用途

队列不仅用于动画。当你没有另外指定时,被这些函数操作的队列是 fx 队列。这是动画使用的默认队列。但是,如果你愿意,你可以创建另一个队列,并添加任意数量的自定义函数和延迟,以便在游戏中脚本一些时间相关的行为。

事件处理

如果您以前使用过 jQuery,您可能在某个时候使用过 .click()。它用于定义一个事件处理程序,用于在 jQuery 中响应鼠标点击。还有许多其他的事件处理程序,从键盘输入、表单提交到窗口调整大小,但我们不会逐一介绍所有这些。而是专注于更 "低级别" 的函数来处理 jQuery 中的事件,并准确解释它们之间的微妙差异。

你通常会使用其中一些函数来实现游戏的控制,无论是通过鼠标还是键盘输入。

.bind()

.bind() 函数是处理事件的基本方式。.click() 例如,只是它的一个包装器。以下示例的两行具有完全相同的效果:

$("#myElementId").click(function(){alert("Clicked!")});
$("#myElementId").bind('click', function(){alert("Clicked!")});

但是,使用 bind 有一个限制。像所有其他 jQuery 函数一样,它仅适用于所选元素。现在,想象一种情况,你想要在用户每次点击具有给定类的链接时执行某些任务。你会写出这样的代码:

$(".myClass").click(function(){/** do something **/});

这将按预期工作,但仅适用于网页中在执行时存在的链接。如果你使用 Ajax 调用更改页面内容,并且新内容也包含具有此类的链接,那么你将不得不再次调用此行代码来增强新链接!

这远非理想,因为你必须手动跟踪你定义的所有事件处理程序,这些处理程序可能需要稍后再次调用,以及你改变页面内容的所有位置。这个过程很可能会出错,你最终会得到一些不一致的结果。

解决这个问题的方法是 .delegate(),详细说明见下一节。

.delegate()

使用 .delegate(),你将事件处理的责任交给了一个父节点。这样,稍后添加到该节点(直接或间接)下面的所有元素仍将看到相应的处理程序执行。

以下代码修复了前面的示例,使其能够与稍后添加的链接一起工作。这意味着所有这些链接都是 ID 属性为 page 的 div 的子元素。

$("#page").delegate(
".myClass", 
"click", 
function(){/** do something **/});

这是解决问题的一个非常优雅的方式,当你创建游戏时,它会非常方便,例如,当你点击精灵时。

移除事件处理程序

如果你需要移除一个事件处理程序,你可以简单地使用 .unbind().undelegate() 函数。

jQuery 1.7

在 jQuery 1.7 中,.delegate().bind() 已被 .on()(以及 .off() 用于移除处理程序)取代。将其视为一个具有像 .bind() 一样行为能力的 .delegate() 函数。如果你理解了 .delegate() 的工作原理,你将没有问题使用 .on()

将数据与 DOM 元素关联

假设你为游戏中的每个敌人创建一个 div 元素。你可能想要将它们与一些数值关联起来,比如它们的生命值。如果你正在编写面向对象的代码,你甚至可能想要关联一个对象。

jQuery 提供了一个简单的方法来做到这一点,即 .data()。这个方法接受一个键和一个值。如果你稍后只调用它的键,它将返回这个值。例如,以下代码将数值 3 与键 "numberOfLife" 关联到了 ID 为 enemy3 的元素上。

 $("#enemy3").data("numberOfLife", 3);

你可能在想,“为什么我不能直接将我的值存储在 DOM 元素上呢?”对此有一个非常好的答案。通过使用 .data(),你完全解耦了你的值和 DOM,这将使得避免因为你仍然在某个地方保持着对它的某个循环引用而导致垃圾回收器没有释放与已移除元素的 DOM 关联的内存的情况变得更容易。

如果你使用 HTML5 数据属性定义了一些值(ejohn.org/blog/html-5-data-attributes/),.data() 函数也会将它们检索出来。

但是,你必须记住调用这个函数会有一些性能成本,如果你需要为一个元素存储许多值,你可能会希望将它们全部存储在与单个键关联的对象字面量中,而不是许多值,每个值都与自己的键关联。

操纵 DOM

使用 jQuery 创建游戏时,您将花费相当多的时间向 DOM 添加和移除节点。例如,您可以创建新的敌人或移除已经死亡的敌人。在下一节中,我们将介绍您将要使用的函数,还将看到它们的工作原理。

.append()

此函数允许您将子元素添加到当前选择的元素(或元素)。它的参数可以是一些已经存在的 DOM 元素、包含描述元素的 HTML 代码的字符串(或一整个元素层次结构),或者是选择某些节点的 jQuery 元素。例如,如果您想要将子元素添加到具有 ID "content" 的节点上,您可以这样写:

$("#content").append("<div>This is a new div!</div>");

请记住,如果您向此函数传递一个字符串,那么内容将需要被解析,如果您经常这样做或者字符串非常大,可能会导致性能问题。

.prepend()

此函数与.append()完全相同,但是将新内容添加到所选元素的第一个子元素之前,而不是添加到其最后一个子元素之后。

.html()

此函数允许您使用作为参数传递的字符串完全替换所选节点的内容。如果没有传递参数调用它,它将返回所选元素中第一个的当前 HTML 内容。

如果您使用空字符串调用它,您将擦除所有节点的内容。这也可以通过调用.empty()来实现。

.html()

.remove()

此函数将简单地删除所有选定的元素并注销所有关联的事件处理程序和数据。

.detach()

在某些情况下,您可能只想暂时删除一些内容,然后稍后再添加。这通常是 .remove() 做得太好的情况。您真正想要的是保留与节点关联的所有其他内容,以便稍后重新添加时,它们将与以前完全相同。.detach() 就是为了这种情况而创建的。它的行为类似于 .remove(),但是允许您轻松重新插入元素。

保持好奇,我的朋友!

就是这样。我真的鼓励您阅读每个函数的 API,因为这里还有一些未显示的参数集。如果对其中任何函数仍然不清楚,不要犹豫在网络上寻找更多关于如何使用它们的示例。由于 jQuery 是如此流行的库,而网络文化是开放的,您将很容易在网上找到大量帮助。

以下是一些可以开始寻找有关 jQuery 更多信息的地方:

摘要

在本章中,我们已经看到了一些对游戏开发非常有用的 jQuery 函数以及如何使用它们。到目前为止,您应该已经熟悉了 jQuery 的哲学和语法。在下一章中,我们将将学到的知识付诸实践,并创建我们的第一个游戏。

第二章:创建我们的第一个游戏

如果你看着电子设备,很有可能上面运行着一个浏览器!你可能在每台 PC 上安装了一个以上的浏览器,并在你的便携设备上运行了更多。如果你想以最低的入门成本将你的游戏分发给广泛的受众,使其在浏览器中运行是非常有意义的。

Flash 长时间以来一直是浏览器中游戏的首选平台,但在过去几年中它的速度逐渐减慢。有很多原因造成了这种情况,并且关于这是否是一件好事有无数的争论。然而,有一个共识是现在你可以在浏览器中以合理的速度运行游戏而无需插件。

本书将重点关注 2D 游戏,因为它们在当前浏览器上运行良好,并且它们依赖的功能已经标准化。这意味着浏览器的更新不应该破坏你的游戏,而且在大多数情况下,你不必过多担心不同浏览器之间的差异。

然而,你很快将能够开发现代 3D 游戏,就像在游戏机上一样,并让它们在浏览器上运行。如果这是你擅长的领域,这本书将为你提供制作这些游戏所需的基本知识。

在本章中,我们将涵盖以下主题:

  • 创建动画精灵

  • 移动精灵

  • 预加载资源

  • 使用有限状态机实现主游戏循环

  • 基本碰撞检测

这本书是如何工作的?

制作游戏有这个惊人的优势,你可以立即看到你刚写的代码的结果在你眼前移动。这就是为什么这本书中学到的一切都将直接应用于一些实际例子的原因。在本章中,我们将一起编写一个受经典游戏青蛙过河启发的小游戏。在接下来的章节中,我们将制作一个平台游戏和一个角色扮演游戏(RPG)。

我真的鼓励你写下你自己版本的这里所介绍的游戏,并修改提供的代码以查看其效果。没有比动手做更好的学习方式了!

让我们认真对待 - 游戏

我们现在将要实现的游戏灵感来自青蛙过河。在这个老派街机游戏中,你扮演一个青蛙,试图通过跳上原木并避开汽车来穿过屏幕。

让我们认真对待 - 游戏

在我们的版本中,玩家是一个开发人员,他必须通过跳跃数据包来穿越网络电缆,然后通过避开错误来穿越浏览器的"道路"。总而言之,游戏规格如下:

  • 如果玩家按一次向上箭头键,"青蛙"将前进一步。

  • 通过按右箭头和左箭头键,玩家可以水平移动。

  • 在第一部分(网络电缆)中,玩家必须跳跃到从屏幕左边出现并向右移动的数据包上。数据包按行组织,每行的数据包以不同的速度行进。一旦玩家站在数据包上,他/她将随之移动。如果数据包把玩家带到屏幕外,或者玩家跳到电缆上未到达数据包,他/她将会死亡,然后重新开始同一级别。

  • 在第二部分(浏览器部分)中,玩家必须躲避从左边出现的错误以穿过浏览器屏幕。如果玩家被错误击中,他/她将会重新开始同一级别。

这些规则非常简单,但正如您将看到的,它们已经给我们提供了很多值得思考的地方。

学习基础

在本书中,我们将使用 DOM 元素来渲染游戏元素。另一个流行的解决方案是使用 Canvas 元素。这两种技术都有各自的优点和缺点,也有一些效果仅通过 DOM 元素是无法实现的。

然而,对于初学者来说,DOM 提供了易于调试的优势,几乎在所有现有的浏览器上运行(是的,即使在 Internet Explorer 6 上也是如此),而且在大多数情况下可以提供合理的游戏速度。DOM 还抽象了繁琐的工作,无需单独针对像素进行操作以及跟踪屏幕的哪一部分需要重新绘制。

即使 Internet Explorer 支持本书中所介绍的大部分功能,我也不建议创建支持 IE 的游戏。事实上,如今它的市场份额微不足道(www.ie6countdown.com/),而且您可能会遇到一些性能问题。

现在介绍一些游戏术语,精灵是游戏的移动部分。它们可以是动画的或非动画的(在改变外观与简单移动之间)。其他游戏的部分可能包括背景、用户界面和图块(我们将在第四章中深入讨论,向旁边看)。

框架

在本书中,我们将编写一些代码;部分代码属于一个示例游戏,并用于描述特定于该游戏的场景或逻辑。但是,某些代码很可能会在您的每个游戏中被重用。因此,我们将把一些这样的功能集中到一个被巧妙地称为gameFramework或简称gf的框架中。

提示

下载示例代码

您可以从www.packtpub.com账户下载您购买过的 Packt 书籍的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,文件将直接通过电子邮件发送给您。

在 JavaScript 中定义命名空间的一个非常简单的方法是创建一个对象,并直接将所有函数添加到其中。以下代码为名称空间cocktail中的两个函数shakestir提供了示例。

// define the namespace
var cocktail = {};

// add the function shake to the namespace
cocktail.shake = function(){...}

// add the function stir to the namespace
cocktail.stir = function(){...}

这样做的优点是避免与其他使用类似名称的库的对象或函数发生冲突。因此,从现在开始,当您看到任何添加到命名空间的函数时,意味着我们认为这些函数将被其他我们稍后在本书中创建的游戏所使用,或者您可能想要自己创建的游戏使用。

下面的代码是另一种命名空间的表示法。您可以使用其中之一是个人偏好,您真的应该使用感觉正确的那个!

var cocktail = {

    // add the function shake to the namespace
   shake: function(){...},

   // add the function stir to the namespace
   stir: function(){...}
};

通常,您会将框架的代码保存在一个 JS 文件中(假设为gameFramework.js),并将游戏的代码保存在另一个 JS 文件中。一旦您的游戏准备发布,您可能希望将所有 JavaScript 代码重新组合到一个文件中(包括 jQuery 如果您愿意的话)并将其最小化。但是,在整个开发阶段,将它们分开将更加方便。

精灵

精灵是您的游戏的基本构建块。它们基本上是可以在屏幕上移动和动画的图像。要创建它们,您可以使用任何图像编辑器。如果您使用的是 OS X,有一个我觉得特别好的免费软件,叫做 Pixen (pixenapp.com/)。

有许多使用 DOM 绘制精灵的方法。最明显的方法是使用img元素。这会带来几个不便。首先,如果要对图像进行动画处理,您有两个选项,但两者都不是没有缺点:

  • 您可以使用动画 GIF。通过这种方法,您无法通过 JavaScript 访问当前帧的索引,并且无法控制动画何时开始播放或何时结束。此外,拥有许多动画 GIF 会导致速度大大减慢。

  • 您可以更改图像的来源。这已经是一个更好的解决方案,但是提出的性能较差,而且需要大量单独的图像。

另一个缺点是您无法选择仅显示图像的一部分;您必须每次都显示整个图像。最后,如果您想要一个由重复图像组成的精灵,您将不得不使用许多img元素。

为了完整起见,我们应该在这里提到img的一个优点;缩放img元素非常容易——只需调整宽度和高度。

提出的解决方案使用了定义尺寸的简单 div,并在背景中设置了图像。要生成动画精灵,您可以更改背景图像,但我们使用的是背景位置 CSS 属性。在此情况下使用的图像称为精灵表,通常看起来像以下的屏幕截图:

精灵

生成动画的机制如下屏幕截图所示:

Sprites

另一个优点是你可以使用单个雪碧图来容纳多个动画。这样你就可以避免加载许多不同的图像。根据情况,您可能仍然希望使用多个雪碧图,但尽量减少它们的数量是件好事。

实现动画

实现这个解决方案非常简单。我们将使用.css()来改变背景属性,并使用简单的setInterval来改变动画的当前帧。因此,假设我们有一个包含 4 帧行走循环的雪碧图,其中每帧测量64 by 64像素。

首先,我们只需创建一个带有雪碧图作为其背景的div。这个div应该测量64 by 64像素,否则下一帧会泄漏到当前帧。在下面的示例中,我们将雪碧图添加到 ID 为mygamediv中。

$("#mygame").append("<div id='sprite1'>");
$("#sprite1").css("backgroundImage","url('spritesheet1.png')");

由于背景图像默认与div的左上角对齐,所以我们只会看到行走循环雪碧图的第一帧。我们想要的是能够改变哪个帧是可见的。以下函数根据传递给它的参数将背景位置更改为正确的位置。请查看以下代码以了解参数的确切含义:

/**
 * This function sets the current frame. 
 * -divId: the Id of the div from which you want to change the
 *         frame
 * -frameNumber: the frame number
 * -frameDimension: the width of a frame
 **/
gameFramework.setFrame = function(divId,frameNumber, frameDimension) {
   $("#"+divId)
      .css("bakgroundPosition", "" + frameNumber * frameDimension + "px 0px");
}

现在我们必须定期调用这个函数来生成动画。我们将使用间隔为 60 毫秒的setInterval,即每秒约 17 帧。这应该足以给人一种行走的印象;然而,这确实必须进行微调,以匹配您的雪碧图。为此,我们使用一个匿名函数传递给setInterval,该函数将进一步使用正确的参数调用我们的函数。

var totalNumberOfFrame = 4;
var frameNumber = 0;
setInterval(function(){
 gameFramework.setFrame("sprite1",frameNumber, 64);
   frameNumber = (frameNumber + 1) % totalNumberOfFrame;
}, 60);

你可能注意到我们正在做一些特殊的事情来计算当前帧。目标是覆盖从 0 到 3 的值(因为有 4 帧),并在达到 4 时循环回到 0。我们用于此的操作称为模数(%),它是整数除法的余数(也称为欧几里德除法)。

例如,在第三帧我们有3 / 4等于 0 加上余数 3,所以3 % 4 = 3。当帧数达到 4 时,我们有4 / 4 = 1加上余数 0,所以4 % 4 = 0。这个机制在很多情况下都被使用。

将动画添加到我们的框架

正如你所看到的,生成动画需要越来越多的变量:图像的 URL、帧数、它们的尺寸、动画的速率和当前帧。此外,所有这些变量都与一个动画相关联,因此如果我们需要第二个动画,我们必须定义两倍数量的变量。

显而易见的解决方案是使用对象。我们将创建一个动画对象,它将保存我们需要的所有变量(现在,它不需要任何方法)。这个对象,像我们框架中所有的东西一样,将位于gameFramework命名空间中。与其将动画的每个属性的所有值作为参数给出,不如使用单个对象文字,并且所有未定义的属性将默认为一些经过深思熟虑的值。

为此,jQuery 提供了一个非常方便的方法:$.extend。这是一个非常强大的方法,你应该真正看一下 API 文档(api.jquery.com/)来看看它能做什么。这里我们将向它传递三个参数:第一个将被第二个的值扩展,结果对象将被第三个的值扩展。

/**
 * Animation Object.
 **/
gf.animation = function(options) {
    var defaultValues = {
        url : false,
        width : 64,
        numberOfFrames : 1,
        currentFrame : 0,
        rate : 30
    };
    $.extend(this, defaultValues, options);
}

要使用此功能,我们只需使用所需值创建一个新实例即可。这里你可以看到在前面的示例中使用的值:

var firstAnim = new gameFramework.animation({
   url: "spritesheet1.png",
   numberOfFrames: 4,
   rate: 60
});

正如你所看到的,我们不需要指定width: 64,因为这是默认值!这种模式非常方便,每次需要默认值和灵活性来覆盖它们时都应该记住它。

我们可以重写函数以使用动画对象:

gf.setFrame = function(divId, animation) {
    $("#" + divId)
        .css("bakgroundPosition", "" + animation.currentFrame * animation.width + "px 0px");
}

现在,我们将根据我们已经看到的技术为我们的框架创建一个函数,但这次它将使用新的动画对象。这个函数将开始对精灵进行动画处理,可以是一次或循环播放。有一件事我们必须注意——如果我们为已经在动画中的精灵定义动画,我们需要停用当前动画,并用新动画替换它。

为此,我们将需要一个数组来保存所有间隔句柄的列表。然后我们只需要检查这个精灵是否存在一个间隔句柄,并清除它,然后再次定义它。

gf.animationHandles = {};

/**
 * Sets the animation for the given sprite.
 **/
gf.setAnimation = function(divId, animation, loop){
    if(gf.animationHandles[divId]){
        clearInterval(gf.animationHandles[divId]);
    }
    if(animation.url){
        $("#"+divId).css("backgroundImage","url('"+animation.url+"')");
    }
    if(animation.numberOfFrame > 1){
        gf.animationHandles[divId] = setInterval(function(){
            animation.currentFrame++;
            if(!loop && currentFrame > animation.numberOfFrame){
                clearInterval(gf.animationHandles[divId]);
                gf.animationHandles[divId] = false;
            } else {
                animation.currentFrame %= animation. numberOfFrame;
                gf.setFrame(divId, animation);
            }
        }, animation.rate);
    }
}

这将提供一个方便、灵活且相当高级的方法来为精灵设置动画。

在画布上移动精灵

现在我们知道如何对精灵进行动画处理了,我们需要将其移动到使其有趣的位置。这需要一些必要条件;首先,我们使用的 div 必须绝对定位。这对于两个原因非常重要:

  • 对开发人员来说,一旦场景变得复杂起来,操作其他定位就成了噩梦。

  • 这是浏览器计算元素位置最不昂贵的方式。

那么我们想要的是精灵相对于包含游戏的 div 定位。这意味着它也必须被定位,绝对,相对或固定。

一旦满足这两个条件,我们就可以简单地使用topleftCSS 属性来选择精灵在屏幕上出现的位置,如下图所示:

在画布上移动精灵

以下代码设置了容器 div 的正确参数并添加了一个精灵:

$("#mygame").css("position", "relative").append("<div id='sprite1' style='position: absolute'>");

由于我们将经常使用这段代码,因此即使它很简单,我们也会将其合并到我们的框架函数中。与我们对动画构造函数所做的一样,我们将使用对象字面量来定义可选参数。

/**
 * This function adds a sprite the div defined by the first argument
 **/
gf.addSprite = function(parentId, divId, options){
    var options = $.extend({
        x: 0,
        y: 0,
        width: 64,
        height: 64
    }, options);

    $("#"+parentId).append("<div id='"+divId+"' style='position: absolute; left:"+options.x+"px; top: "+options.y+"px; width: "+options.width+"px ;height: "+options.height+"px'></div>");
}

然后我们将编写一个函数,使一个精灵沿着 x 轴移动,另一个精灵沿着 y 轴移动。图形编程中的一个典型约定是将 x 轴从左到右,y 轴从上到下。这些函数将接受要移动的元素的 ID 和要移动到的位置作为参数。为了模仿一些 jQuery 函数的工作方式,如果你不提供第二个参数,我们的函数将返回精灵的当前位置。

/**
 * This function sets or returns the position along the x-axis.
 **/
gf.x = function(divId,position) {
    if(position) {
        $("#"+divId).css("left", position); 
    } else {
        return parseInt($("#"+divId).css("left")); 
    }
}
/**
 * This function sets or returns the position along the y-axis.
 **/
gf.y = function(divId,position) {
    if(position) {
        $("#"+divId).css("top", position); 
    } else {
        return parseInt($("#"+divId).css("top")); 
    }
}

有了这三个简单的函数,你就拥有了生成游戏图形所需的所有基本工具。

预加载

然而,在大多数情况下还需要最后一件事情;资源加载。为了避免在一些图片加载完成之前启动游戏,你需要在游戏开始之前加载它们。大多数用户希望游戏只在他们决定启动它时开始加载。此外,他们想要一些关于加载过程进度的反馈。

在 JavaScript 中,你有可能为每张图片定义一个在图片加载完成后将被调用的函数。然而,这有一个限制,它不会提供关于其他图片的信息。而且你不能简单地为最后一张开始运行的图片定义一个回调,因为你无法保证图片加载的顺序,而且在大多数情况下,图片不是依次加载的,而是一次性加载一堆。

有许多可能的解决方案,大多数都同样出色。由于这段代码大多数情况下只运行一次,而且在游戏开始之前,性能在这里并不是特别重要。真正想要的是一个稳健、灵活的系统,能够知道所有图片都加载完毕的情况,并且能够追踪总体进度。

我们的解决方案将使用两个函数:一个用于将图片添加到预加载列表中,另一个用于开始预加载。

gf.imagesToPreload = [];

/**
 * Add an image to the list of image to preload
 **/
gf.addImage = function(url) {
    if ($.inArray(url, gf.imagesToPreload) < 0) {
        gf.imagesToPreload.push();
    }
    gf.imagesToPreload.push(url);
};

这个第一个函数并不做太多事情。它只是获取一个 URL,检查它是否已经存在于我们存储预加载图片的数组中,如果新图片不在数组中,则将其添加进去。

下一个函数接受两个回调函数。第一个回调函数在所有图片加载完成时调用,第二个回调函数(如果定义了)以百分比的形式调用当前进度。

/**
 * Start the preloading of the images.
 **/
gf.startPreloading = function(endCallback, progressCallback) {
    var images = [];
    var total = gf.imagesToPreload.length;

    for (var i = 0; i < total; i++) {
        var image = new Image();
        images.push(image);
        image.src = gf.imagesToPreload[i];
    }
    var preloadingPoller = setInterval(function() {
        var counter = 0;
        var total = gf.imagesToPreload.length;
        for (var i = 0; i < total; i++) {
            if (images[i].complete) {
                counter++;
            }
        }
        if (counter == total) {
            //we are done!
            clearInterval(preloadingPoller);
            endCallback();
        } else {
            if (progressCallback) {
                count++;
                progressCallback((count / total) * 100);
            }
        }
    }, 100);
}; 

在这个函数中,我们首先为添加到列表中的每个 URL 定义一个新的 Image 对象。它们将自动开始加载。然后我们定义一个将定期调用的函数。它将使用图片的 complete 属性来检查每个图片是否已加载。如果加载完毕的图片数量等于总图片数量,这意味着我们已经完成了预加载。

有用的是自动将用于动画的图像添加到预加载列表中。为此,只需在动画对象的末尾添加三行代码,如下所示:

gf.animation = function(options) {
    var defaultValues = {
        url : false,
        width : 64,
        numberOfFrames : 1,
        currentFrame : 0,
        rate : 30
    };
    $.extend(this, defaultValues, options);
    if(this.url){
        gf.addImage(this.url);
    }
}

初始化游戏

游戏的框架部分已经完成。现在我们想要实现图形和游戏逻辑。我们可以将游戏的代码分为两部分,一部分仅在开头执行一次,另一部分会定期调用。我们将第一个称为初始化。

只要图像加载完成,就应立即执行这部分;这就是为什么我们将它作为startPreloading函数的结束回调。这意味着在一开始时,我们需要将所有要使用的图像添加到预加载列表中。然后,一旦用户启动游戏(例如通过点击 ID 为startButton的图像),我们将调用预加载程序。

以下代码使用标准的 jQuery 方式在页面准备就绪后执行函数。我不会在这里提供完整的代码,因为一些代码相当重复,但我会至少给出每个这里执行的动作的一个示例,如果你感兴趣,你可以随时查看完整的源代码。

$(function() {
    var backgroundAnim = new gf.animation({
        url : "back.png"
    });
    var networkPacketsAnim = new gf.animation({
        url : "packet.png"
    });
    var bugsAnim = new gf.animation({
        url : "bug.png"
    });
    var playerAnim = new gf.animation({
        url : "player.png"
    });

    var initialize = /* we will define the function later */

   $("#startButton").click(function() {
         gf.startPreloading(initialize);
       });
});

以下是我们在初始化函数中需要做的事情列表:

  • 创建组成游戏场景的精灵

  • 创建 GUI 元素

下图展示了我们将如何构建游戏场景:

初始化游戏

不超过八个精灵:一个用于背景,一个用于玩家,三个用于网络数据包,和三个用于虫子。为了简化事情,我们只使用每个数据包/虫子组的一个精灵。这三组数据包将有相同的动画,三组虫子也一样。

为了避免添加元素时它们突然出现,我们将它们首先添加到一个不可见的元素中,直到所有精灵都创建完毕后才使此元素可见。

唯一的 GUI 元素将是包含玩家生命数的小div

var initialize = function() {
    $("#mygame").append("<div id='container' style='display: none; width: 640px; height: 480px;'>");
    gf.addSprite("container","background",{width: 640, height: 480});
    gf.addSprite("container","packets1",{width: 640, height: 40, y: 400});
    /* and so on */
    gf.addSprite("container","player",{width: 40, height: 40, y: 440, x: 260});

    gf.setAnimation("background", backgroundAnim);
    gf.setAnimation("player", playerAnim);
    gf.setAnimation("packets1", networkPacketsAnim);
    /* and so on */    

    $("#startButton").remove();
    $("#container").append("<div id='lifes' style='position: relative; color: #FFF;'>life: 3</div>").css("display", "block");
    setInterval(gameLoop, 100);
}

这个函数的最后一行是启动主循环。主循环是会定期执行的代码。它包含大部分(如果不是全部)与玩家输入没有直接关联的游戏逻辑。

主循环

主循环通常包含一个有限状态机(FSM)。FSM 由一系列状态和从一个状态到另一个状态的转换列表定义。简单游戏的 FSM,玩家需要依次点击三个出现的方框,看起来会像以下的图表:

主循环

当你实现 FSM 时,你需要考虑两件事:游戏在每个状态下应该如何行为,以及什么条件使游戏转移到新状态。FSM 的优势在于它们提供了一种正式的方法来组织游戏逻辑。这将使您更容易阅读您的代码,并且如果需要的话,您可以随时添加/更改您的逻辑。我建议你先为你的游戏绘制 FSM,并将其放在某个地方,以帮助你调试你的游戏。

对于我们的Frogger游戏,有 10 个状态。初始状态是START,两个最终状态分别是GAMEOVERWON。以下是每个状态中确切发生的描述:

  • 所有状态:数据包和虫子向右移动

  • STARTPOS:没有特殊情况发生

  • LINE1:玩家以第一行数据包的速度移动;如果玩家走出屏幕,就会死亡并回到START

  • LINE2:玩家以第二行数据包的速度移动,如果玩家走出屏幕,就会死亡并回到START

  • LINE3:玩家以第三行数据包的速度移动,如果玩家走出屏幕,就会死亡并回到START

  • REST:没有特殊情况发生

  • LINE4:如果玩家被第四行的虫子击中,就会死亡并回到REST

  • LINE5:如果玩家被第一行的虫子击中,就会死亡并回到REST

  • LINE6:如果玩家被第六行的虫子击中,就会死亡并回到REST

  • WONGAMEOVER:没有特殊情况发生

除了WONGAMEOVER状态外,玩家可以四处移动。这将触发以下转换:

  • 成功跳跃:转到下一个状态

  • 成功向左/向右滑动:保持在相同状态

  • 向左/向右滑动失败的跳转:如果剩余生命大于零,回到上次的“安全”状态(STARTREST),否则转移到GAMEOVER

主循环实现

编写 FSM 最易读的方法是使用 switch 语句。我们将使用两个,一个在主循环中更新游戏,另一个用于处理键盘输入。

以下代码是主循环的一部分。我们首先初始化一些变量,这些变量将用于定义游戏的行为,然后编写前面部分描述的 FSM。为了移动数据包和虫子,我们将使用一个技巧,简单地改变background-position。这比我们之前编写的函数少了灵活性,但在这种情况下更快,并且很容易让人以一个精灵给出无限数量的元素的假象。

var screenWidth = 640;
var packets1 = {
    position: 300,
    speed: 3
}
/* and so on */

var gameState = "START";

var gameLoop = function() {
    packets1.position += packets1.speed;
    $("#packets1").css("background-position",""+ packets1.position +"px 0px");

   /* and so on */

    var newPos = gf.x("player");
    switch(gameState){
        case "LINE1":
            newPos += packets1.speed;
            break;
        case "LINE2":
            newPos += packets2.speed;
            break;
        case "LINE3":
            newPos += packets3.speed;
            break;
    }
    gf.x("player", newPos);
};

此时,游戏显示了所有移动的部分。 仍然没有办法让玩家控制其化身。 为了做到这一点,我们将使用 keydown 事件处理程序。 我们将实现两种不同的方案来移动角色。 对于水平移动,我们将使用之前编写的 gf.x 函数。 这是有道理的,因为这是一个非常小的移动,但对于垂直跳跃,我们将使用 $.animate 以使化身在许多步骤中移动到目的地,并创建更流畅的移动。

$(document).keydown(function(e){
        if(gameState != "WON" && gameState != "GAMEOVER"){
            switch(e.keyCode){
                case 37: //left
                    gf.x("player",gf.x("player") - 5);
                    break;
                case 39: // right
                    gf.x("player",gf.x("player") + 5);
                    break;
                case 38: // jump
                    switch(gameState){
                        case "START":
                            $("#player").animate({top: 400},function(){
                                gameState = "LINE1";
                            });
                            break;
                        case "LINE1":
                            $("#player").animate({top: 330},function(){
                                gameState = "LINE2";
                            });
                            break;
                        /* and so on */
                        case "LINE6":
                            $("#player").animate({top: 0},function(){
                                gameState = "WON";
                                $("#lifes").html("You won!");
                            });
                            break;
                    }
            }
        }
    });

在这里,我们开始检查游戏的状态,以确保玩家被允许移动。 然后我们检查按下了哪个键。 左右部分都很简单明了,但跳跃部分要微妙些。

我们需要检查游戏的状态来找出玩家应该跳到哪里。 然后,我们使用传递给 animate 函数的回调来更新游戏的状态,这样只有在动画完成后才会更新游戏的状态。

就是这样,你现在可以控制玩家了。如果你跳上了一个数据包,玩家将会随着它移动,当你到达终点时,你就赢得了游戏。 不过,你可能已经注意到我们忘记了一些重要的东西:没有办法让玩家死亡! 要添加这个功能,我们需要检测玩家是否处于安全位置。

碰撞检测

我们将使用某种碰撞检测,但这只是针对这种情况设计的非常简单的版本。 在后面的章节中,我们将会看到更一般的解决方案,但在这里不是必要的。

在这个游戏中,碰撞检测有六个重要的地方;第一部分中的三行数据包和第二部分中的三行臭虫。 两者代表完全相同的情况。 一系列的元素被一些空间分隔开。 每个元素之间的距离是恒定的,大小也是恒定的。 我们不需要知道玩家跳到了哪个数据包上,或者哪些臭虫打中了玩家,重要的是玩家是否站在了一个数据包上,或者是否被臭虫击中了。

因此我们将使用我们之前使用过的取模技巧来降低问题的复杂性。 我们要考虑的是以下的情况:

碰撞检测

要知道玩家是否触碰了元素,我们只需比较其 x 坐标与元素位置即可。

以下代码就做到了这一点。 首先,它检查游戏状态以了解要检测的碰撞(如果有的话),然后使用取模运算将玩家带回我们想要考虑的简化情况。 最后,它检查玩家的坐标。

var detectSafe = function(state){
    switch(state){
        case "LINE1":
            var relativePosition = (gf.x("player") - packets1.position) % 230;
            relativePosition = (relativePosition < 0) ? relativePosition + 230: relativePosition;
            if(relativePosition > 110 && relativePosition < 210) {
                return true;
            } else {
                return false;
            }
            break;
        /* and so on */ 
        case "LINE4":
            var relativePosition = (gf.x("player") - bugs1.position) % 190;
            relativePosition = (relativePosition < 0) ? relativePosition + 190: relativePosition;
            if(relativePosition < 130) {
                return true;
            } else {
                return false;
            }
            break;
        /* and so on */
    }
    return true;
}

还有一件小事情你需要注意:取模运算可能会得到负值。 这就是为什么我们需要检查并简单地加上重复部分的宽度以转换为正值。

这是一种相当快速的检测解决方案的方法,有许多这样的情况,你可以设计自己的碰撞检测,并且使其非常有效,因为你知道在你特定的情况下需要检查什么。

现在我们可以在我们的游戏中调用这个方法。有两个地方应该这样做:在主循环中和在输入处理程序中。当我们检测到玩家死亡时,我们需要减少其生命并将其移动到正确的位置。此外,我们希望在这种情况下检测到玩家没有生命了,并将游戏状态更改为GAMEOVER。以下函数就是这样做的:

var life = 3;
var kill = function (){
    life--;
    if(life == 0) {
        gameState = "GAMEOVER";
        $("#lifes").html("Game Over!");
    } else {
        $("#lifes").html("life: "+life);
        switch(gameState){
            case "START":
            case "LINE1":
            case "LINE2":
            case "LINE3":
                gf.x("player", 260);
                gf.y("player", 440);
                gameState = "START";
                break;
            case "REST":
            case "LINE4":
            case "LINE5":
            case "LINE6":
                gf.x("player", 260);
                gf.y("player", 220);
                gameState = "REST";
                break;
        }
    }
}

现在我们可以在主循环中添加碰撞检测。我们还需要检查另一件事:玩家不应该在其中一个数据包中走出屏幕。

var newPos = gf.x("player");
switch(gameState){
    case "LINE1":
        newPos += packets1.speed;
        break;
    /* and so on */
}
if(newPos > screenWidth || newPos < -40){
        kill();
} else {
    if(!detectSafe(gameState)){
        kill();
    }
    gf.x("player", newPos);
}

在输入处理程序中,我们将代码添加到跳跃动画结束时执行的回调中。例如,要检查从起始位置跳到第一行的碰撞,我们将编写以下内容:

case "START":
    $("#player").animate({top: 400},function(){
        if(detectSafe("LINE1")){
            gameState = "LINE1";
        } else {
            kill();
        }
    });
    break;

这里你可以看到为什么我们在kill函数中没有使用gameState。在这种情况下,玩家仍处于其先前的状态。它仍然没有“着陆”,可以这么说。只有在跳跃安全时,我们才会将玩家的状态更改为下一行。

摘要

现在我们有了一个完全实现了我们在本章开头定义的规范的游戏。代码还没有优化,这将是我们下一章的主题,但为了制作一个好玩的游戏,它确实需要更多的打磨。你可以添加一个高分系统,与社交网络集成,以及声音和触摸设备兼容性。

我们将在未来的章节中涵盖这些主题及更多内容。然而,有很多事情你现在已经学到了,可以用来改善游戏:你可能想为玩家死亡时添加动画,一个更好的 GUI,更漂亮的图形,能够向后跳跃,以及不止一个关卡。正是这些小细节将使你的游戏脱颖而出,你真的应该投入大部分时间来给你的游戏以专业的完成!

第三章:更好、更快,但不更难

我们刚刚开发的游戏在几乎所有设备和几乎所有浏览器上都能正常工作,主要原因是它非常简单,包含很少的移动精灵。然而,一旦你尝试制作一个像我们在接下来的章节中将要制作的更复杂的游戏,你会意识到你需要非常小心地编写优化代码以获得良好的性能。

在本章中,我们将回顾我们之前的代码,并提出某些方面的优化版本。其中一些优化是为了使您的游戏运行更快,而另一些是为了使您的代码更可读和更易于维护。

一般来说,实现游戏的第一个版本时,最好减少一些功能,不要过多担心性能问题,然后进行优化并添加更多功能。这有助于避免花费过多时间在游戏中可能不需要的东西上,允许您对优化进行基准测试,以确保它们真正加快了速度,最重要的是,保持您的动力。

在本章中,我们将深入探讨以下几个方面:

  • 减少间隔和超时的数量

  • 键盘轮询

  • 使用 HTML 片段

  • 避免重新排版

  • 使用 CSS Transform 加速精灵定位

  • 使用requestAnimationFrame代替超时

间隔和超时

在我们的游戏中,我们使用了许多setInterval调用。你可能会认为这些调用是多线程的,但实际上并不是。JavaScript 是严格单线程的(最近的一个例外是 WebWorkers,但我们这里不会深入讨论)。这意味着所有这些调用实际上都是依次运行的。

如果你对间隔和超时的工作原理感兴趣,我建议阅读 John Resig 撰写的优秀文章,JavaScript 计时器的工作原理ejohn.org/blog/how-javascript-timers-work/)。

因此,间隔和超时并不会为您的代码添加多线程,有许多原因可能会使您希望避免过多使用它们。首先,它使您的代码有些难以调试。实际上,根据每次调用需要花费的时间,您的间隔将以不同的顺序执行,并且即使这些调用的周期性完全相同,它们也会有所不同。

此外,从性能方面考虑,过多使用setIntervalsetTimeout可能会对较老的浏览器造成很大的负担。

另一种选择是使用一个单一的间隔来替换所有你的动画函数和游戏循环。

一次间隔统治它们

使用一个单一的间隔并不一定意味着你希望所有的动画以相同的速率执行。在大多数情况下,一个可接受的解决方案是允许任何基本间隔的倍数来执行动画。

通常,您的游戏循环将以给定的速率运行(假设为 30 毫秒),而您的动画将以相同的速率运行,或者是两倍、三倍、四倍等速率。但是,这并不局限于动画;您可能希望有多个游戏循环,其中一些以更低的速率执行。

例如,您可能希望在平台游戏中每秒增加水的水平。这样,玩家就有动力尽快完成关卡,否则他/她将会淹死。为了在框架中实现这一点,我们将添加一个addCallback函数,该函数将接受一个函数和一个速率。我们先前游戏中的游戏循环将使用此函数实现,而不是setInterval

这意味着startPreloading函数将略有变化。在调用endCallback函数后,我们将启动一个setInterval函数,其中包含一个新函数,该函数将调用所有通过addCallback定义的函数,并负责动画。此外,我们将其简单地更名为startGame以反映用法的变化。

在游戏中,不需要显式地创建具有游戏循环的间隔,因为这由startGame函数自动完成;我们只需使用addCallback函数将其添加到游戏中。以下图片显示了这种方法与使用许多setTimeout函数的方法的比较:

统一的间隔

我们将通过向initialize函数提供这个最小刷新率来在我们的框架中实现这一点。从这一点开始,所有动画和周期性函数将被定义为它的倍数。我们仍然在 API 中使用毫秒来描述它们的速率,但是将速率内部存储为基础速率的最接近倍数。

代码

我们的初始化函数将使用我们之前使用的$.extend函数。从现在开始,我们将只有基本刷新率,但随着需要,我们将添加更多值。我们还需要定义基本刷新率的默认值,以解决用户未手动指定时的情况。

gf = {
    baseRate: 30
};

gf.initialize = function(options) {
    $.extend(gf, options);
} 

新更名的startGame函数将如下所示代码所示:

gf.startGame = function(progressCallback) {
    /* ... */
    var preloadingPoller = setInterval(function() {
        /* ... */
        if (counter == total) {
            //we are done!
            clearInterval(preloadingPoller);
            endCallback();
            setInterval(gf.refreshGame, gf.baseRate);
        } else {
            /* ... */
        }
    }, 100);
};

在这里我们没有改变太多;在endCallback函数之后,我们添加了对内部函数的调用:gf.refreshGame。正是这个函数将协调动画的刷新和周期性函数调用。

这个新函数将使用两个列表来知道何时做什么,一个用于回调,一个用于动画。我们已经有一个用于动画的列表:gf.animationHandles。我们将其简单重命名为gf.animations并创建第二个名为gf.callbacks的列表。

两个列表都必须包含一种方法来知道它们是否应在基础速率的当前迭代中执行。为了检测这一点,我们将为每个动画和回调使用一个简单的计数器。每次基本循环执行时,我们将递增所有这些计数器,并将它们的值与关联动画/回调的速率进行比较。如果它们相等,这意味着我们需要执行它并重置计数器。

gf.refreshGame = function (){
    // update animations
    var finishedAnimations = [];

    for (var i=0; i < gf.animations.length; i++) {

        var animate = gf.animations[i];

        animate.counter++;
        if (animate.counter == animate.animation.rate) {
            animate.counter = 0;
            animate.animation.currentFrame++;
            if(!animate.loop && animate.animation.currentFrame > animate.animation.numberOfFrame){
                finishedAnimations.push(i);
            } else {
                animate.animation.currentFrame %= animate.animation.numberOfFrame;
                gf.setFrame(animate.div, animate.animation);
            }
        }
    }
    for(var i=0; i < finishedAnimations.length; i++){
        gf.animations.splice(finishedAnimations[i], 1);
    }

    // execute the callbacks
    for (var i=0; i < gf.callbacks.length; i++) {
        var call  = gf.callbacks[i];

        call.counter++;
        if (call.counter == call.rate) {
            call.counter = 0;
            call.callback();
        }
    }
} 

这个简单的机制将替换对setInterval的许多调用并解决我们之前提到的与此相关的问题。

将动画设置为 div 的函数必须相应地进行调整。就像你在前面的示例中看到的那样,负责确定动画帧的实际代码现在在refreshGame函数中。这意味着setAnimation函数只需将动画添加到列表中,而不必关心如何进行动画化。

函数的一部分检查 div 是否已经与动画关联起来现在稍微复杂了一些,但是除此之外,函数现在更简单了。

gf.animations = [];

/**
 * Sets the animation for the given sprite.
 **/
gf.setAnimation = function(divId, animation, loop){
    var animate = {
 animation: animation,        
 div: divId,

        loop: loop,
        counter: 0
    }

    if(animation.url){
        $("#"+divId).css("backgroundImage","url('"+animation.url+"')");
    }

    // search if this div already has an animation
    var divFound = false;
    for (var i = 0; i < gf.animations.length; i++) {
        if(gf.animations[i].div == divId){
            divFound = true;
            gf.animations[i] = animate
        }
    }

    // otherwise we add it to the array
    if(!divFound) {
        gf.animations.push(animate);
    }
} 

我们需要编写类似的代码将回调添加到基础循环中:

gf.callbacks = [];

gf.addCallback = function(callback, rate){
    gf.callbacks.push({
        callback: callback,
        rate: Math.round(rate / gf.baseRate),
        counter: 0
    });
}

这个函数很琐碎;唯一有趣的部分是将刷新率标准化为基础速率的倍数。你可能注意到我们在动画方面没有做任何这样的事情,但是现在我们将在创建动画的函数中执行此操作。它现在将是这样的:

gf.animation = function(options) {
    var defaultValues = {
        url : false,
        width : 64,
        numberOfFrames : 1,
        currentFrame : 0,
        rate : 1
    }
    $.extend(this, defaultValues, options);
    if(options.rate){
        // normalize the animation rate
        this.rate = Math.round(this.rate / gf.baseRate);
    }
    if(this.url){
        gf.addImage(this.url);
    }
}

就是这样;通过这些简单的改变,我们将摆脱大多数setInterval函数。将功能与普通的 JavaScript 一起获得的功能复制似乎需要相当多的工作,但是你会发现随着时间的推移,当你开始调试你的游戏时,它确实会帮助很多。

键盘轮询

如果你玩过上一章的游戏,你可能会注意到我们的“青蛙”从左到右的移动有些奇怪,也就是说,如果你按住左键,你的角色会向左移动一点,停顿一段时间,然后开始持续向左移动。

这种行为并不是由浏览器直接引起的,而是由操作系统引起的。这里发生的情况是,当键长时间保持按下时,操作系统会重复任何键(也称为“粘滞键”)。有两个参数定义了这种行为:

  • 宽限期:这是操作系统在重复按键之前等待的时间。这样做可以避免在你确实只想按一次时重复按键。

  • 按键重复的频率。

你对这些参数或此行为的发生没有控制权。这一切都取决于操作系统和用户配置的方式。

对于连续动作,这远非理想。如果你在 RPG 或平台游戏中移动角色,你需要移动是连续且线性的速度。这个问题的解决方案被称为状态轮询。使用这种方法,你希望主动查询一些键的状态,而不是等待状态的改变,就像事件处理中所做的那样。

在你的游戏循环中,你会在某个时刻询问键“left”是否被按下,并根据情况做出反应。这在本地游戏中经常使用,但是 JavaScript 并没有提供这种可能性。我们将不得不自己实现一个状态轮询技术。

跟踪按键状态

为了做到这一点,我们将使用唯一可用的工具:keydownkeyup 事件。我们将注册两个事件处理程序:

  1. 如果按下具有给定键码“c”的键,则第一个事件处理程序将在索引“c”处的数组中写入 true

  2. 当相同的键释放时,第二个事件处理程序将索引“c”的值设置为 false

这种解决方案的一个好处是,我们不需要为每个可能的键初始化数组的状态,因为默认情况下它是未定义的;所以,当我们检查时,它的值将返回 false。下图说明了这两个事件处理程序的工作原理:

跟踪按键状态

我们将在我们的框架末尾注册这两个事件处理程序:

gf.keyboard = [];
// keyboard state handler
 $(document).keydown(function(event){
    gf.keyboard[event.keyCode] = true;
});
$(document).keyup(function(event){
    gf.keyboard[event.keyCode] = false;
});

一旦完成了这一步,我们就可以简单地将处理左右移动的代码移动到游戏循环中,并重写它以使用 gf.keyboard 数组。

if(gf.keyboard[37]){ //left
    newPos -= 5;
}
if(gf.keyboard[39]){ //right
    newPos += 5;
}

在这里,我们不需要检查玩家是否死亡,因为我们在游戏循环中已经这样做了。你只需要记住,可能会同时按下多个键。这在以前版本中不是这样的,在以前版本中,使用事件处理程序,每按下一个键就会生成一个事件。

如果现在尝试游戏,你会注意到你的玩家的水平移动要好得多。

如你所见,使用轮询的代码更漂亮,在大多数情况下更紧凑。此外,它在游戏循环内部,这总是一件好事。然而,仍然存在一些可能不是最佳解决方案的情况。使我们的青蛙跳跃就是一个很好的例子。

在选择事件处理和轮询之间,真正取决于情况,但一般来说,如果你想对按键做出一次反应,你会使用事件,如果你想对按键持续做出反应,你会使用轮询。

HTML 片段

在这里,我们将看一些创建精灵的代码中的小优化。由于这个函数在我们整个游戏中仅调用了八次,并且仅在初始化阶段调用,所以在这种情况下它的速度并不是很重要。然而,在许多情况下,你需要在游戏过程中创建大量的精灵,例如,在射击游戏中射击激光时,创建平台游戏的关卡或 RPG 地图时。

这种技术避免了每次将精灵添加到游戏中都解析 HTML 代码(描述一个精灵)。它使用了所谓的 HTML 片段,这是一种从常规 HTML 节点树中截取的分支。

HTML 片段

jQuery 提供了一种非常简单的方法来生成这样的片段:

var fragment = $("<div>fragment</div>");

在这个例子中,变量 fragment 将在内存中保存 HTML 元素,直到我们需要使用它。它不会自动添加到文档中。如果以后想要添加它,只需简单地编写:

$("#myDiv").append(fragment);

请记住,片段仍然引用着已添加的元素,这意味着如果稍后将其添加到另一个位置,它将从先前的位置删除,并且如果修改它,你也将修改文档。

要避免这种情况,你需要在将片段插入文档之前将其克隆,如下代码所示:

$("#myDiv").append(fragment.clone());

这正是我们将重写 addSprite 函数使其更快的方式:

gf.spriteFragment = $("<div style='position: absolute'></div>");
gf.addSprite = function(parentId, divId, options){
    var options = $.extend({}, {
        x: 0,
        y: 0,
        width: 64,
        height: 64
    }, options);
    $("#"+parentId).append(gf.spriteFragment.clone().css({
            left:   options.x,
            top:    options.y,
            width:  options.width,
            height: options.height}).attr("id",divId));
}; 

在这里,我们为每个精灵共同的部分创建了一个片段。然后,在将其添加到文档之前,我们克隆它并添加了 addSprite 函数提供的特殊参数,例如它的位置、大小和 ID。

就像我之前说的,对于我们非常简单的游戏,你可能不会注意到任何可见的变化,但这段代码更高效,在我们生成大量精灵的更复杂的游戏中会很方便。

避免回流

在修改 DOM 时,必须尽量避免生成整个文档或大部分文档的完全回流。有许多方法可以最小化做到这一点的风险,而且现代浏览器在进行优化时做得相当不错。

通常,浏览器会尝试在重新回流文档之前尽可能地重新组织修改。然而,如果尝试访问依赖于这些修改之一的信息,它将不得不执行重新回流以便能够计算新信息。

一个相当不错的经验法则是尽量避免像瘟疫一样读取 DOM,并作为最后的手段,将所有读取分组,并在刷新循环结束时执行它们。

在我们的游戏中,有一个地方我们正处于这种情况:每次访问玩家角色的 X 位置时,我们都会强制浏览器重新回流。在游戏循环中,位置和大小可能是最经常访问的信息之一。加快速度的一个简单方法是避免从 DOM 获取它们。事实上,只要它们通过框架函数设置,我们就可以简单地将它们存储在某个地方,并在需要时检索它们。

为此,我们将使用 jQuery 的 data 函数将我们的精灵与包含这些有趣值的对象文字关联起来。addSprite 函数将以此方式扩展:

gf.addSprite = function(parentId, divId, options){
    /* ... */
    $("#"+parentId).append(gf.spriteFragment.clone().css({
            left:   options.x,
            top:    options.y,
            width:  options.width,
            height: options.height}).attr("id",divId).data("gf",options));
}

然后,在 gf.xgf.y 函数中,我们将使用这个值而不是 CSS 属性:

gf.x = function(divId,position) {
    if(position) {
        $("#"+divId).css("left", position); 
 $("#"+divId).data("gf").x = position;
    } else {
 return $("#"+divId).data("gf").x; 
    }
}
gf.y = function(divId,position) {
    if(position) {
        $("#"+divId).css("top", position); 
 $("#"+divId).data("gf").y = position;
    } else {
 return $("#"+divId).data("gf").y; 
    }
}

这还有一个好处,就是消除了两个 parseInt 值,而且游戏的代码甚至不需要改变!

使用 CSS 转换移动您的精灵

使用 CSS 转换是一种简单的技巧,可以让您比使用 CSS topleft 属性在屏幕上移动对象更快。如果您决定使用这个,您必须意识到并非所有的浏览器都支持它。

我们不会进入太多细节,因为 CSS 转换在下一章环视中有解释。下面的代码是使用 CSS 转换所需的修改:

gf.x = function(divId,position) {
    if(position) {
        var data = $("#"+divId).data("gf");
        var y = data.y;
        data.x = position;
        $("#"+divId).css("transform", "translate("+position+"px, "+y+"px)");
    } else {
        return $("#"+divId).data("gf").x; 
    }
}
gf.y = function(divId,position) {
    if(position) {
        var data = $("#"+divId).data("gf");
        var x = data.x;
        data.y = position;
        $("#"+divId).css("transform", "translate("+x+"px, "+position+"px)"); 
    } else {
        return $("#"+divId).data("gf").y; 
    }
}

正如您在代码的突出部分中所看到的,我们需要每次设置好两个坐标。这意味着当我们修改 x 坐标时,我们必须检索 y 坐标,反之亦然。

使用 requestAnimationFrame 而不是 timeouts

最近在浏览器中添加了一个新的功能,以使动画更加流畅: requestAnimationFrame。这使得浏览器告诉您什么时候最适合动画您的页面,而不是在您喜欢的任何时间都进行动画。您应该使用这个代替使用 setIntervalsetTimeout 来注册回调。

当您使用 requestAnimationFrame 时,是浏览器决定何时调用函数。因此,您必须考虑自上次调用以来过去的确切时间。用于定义此时间的标准规范是毫秒(就像您使用 Date.now() 可以得到的那些),但现在由一个高精度计时器给出。

由于这两个版本的实现存在,而且该功能在大多数浏览器中都有供应商前缀,您应该使用一个工具来抽象脏细节。我建议阅读以下两篇文章,两篇文章都提供了可用的代码片段:

总结

在这一章中,我们花了一些时间优化了我们在第二章中编写的游戏,创建我们的第一个游戏。我们看到了一些优化技术,将使我们的游戏更加流畅,而不会影响我们的代码可读性。

我们建立的框架现在是一个合理的基础,我们可以在接下来的章节中构建一个更完整的框架。我们将在接下来的章节中开始添加创建瓷砖地图的能力,这些地图将用于实现一个平台游戏。

第四章:横看成岭侧成峰

现在是时候制作一个更复杂的游戏了。我们将实现一个非常流行的类型,即 2D 平台游戏。这一类型的早期示例包括 超级马里奥兄弟索尼克小子。这些游戏通常使用小型重复的精灵,称为瓦片地图,进行关卡设计。我们将添加这些内容,以及更通用的碰撞检测,到我们的框架中。对于游戏逻辑本身,我们将使用面向对象的代码。

这是我们将不得不添加到我们的框架中的功能的快速列表:

  • 离线 div

  • 分组

  • 精灵变换

  • 瓦片地图

  • 碰撞检测

首先,我们将逐个遍历所有这些,然后开始游戏。

离线 div

如前一章节末尾所解释的那样,避免重排是加快速度的好方法。在进行操作时完全避免查询 DOM 状态并不总是容易的。即使您非常小心,作为框架开发者,您也永远不确定您的框架的用户会做什么。然而,有一种方法可以减少重排的负面影响;分离您正在操作的 DOM 片段,修改它,然后将其重新附加到文档中。

假设您有一个带有 ID box 的节点,并且想要以复杂的方式操纵其子元素。以下代码向您展示了如何分离它:

// detach box
var box = $("#box").detach();

var aSubElement = box.find("#aSubElement")
// and so on

// attach it back
box.appendTo(boxParent);

这需要对我们的框架 API 进行小的修改;到目前为止,我们使用字符串来标识精灵。这会导致需要将精灵作为文档的一部分。例如,如果您调用 gf.x("sprite"),jQuery 将尝试在文档中查找 ID 为 sprite 的节点。如果分离精灵或其父级之一,则该函数将找不到其 ID。

解决方案很简单,只需将 DOM 节点本身提供给我们框架的函数。由于我们使用 jQuery,因此我们将在 jQuery 中包装此节点。让我们比较当前 API 和提议的 gf.x 函数的 API。

// current API
var xCoordinate = gf.x("mySprite");

// proposed API
var xCoordinate = gf.x($("#mySprite"));

此解决方案还有另一个优点;它允许进一步优化。如果我们看一下此函数的实现,我们会发现另一个问题:

gf.x = function(divId,position) {
    if(position) {
        $("#"+divId).css("left", position);
        $("#"+divId).data("gf").x = position;
    } else {
        return $("#"+divId).data("gf").x; 
    }
}

每次调用函数时,都可以看到 jQuery 被用于检索元素。任何对 DOM 的访问(即使在选择器中使用元素的 ID 来查找元素)都会产生性能成本。理想情况下,如果相关元素被使用超过几次,您可能希望对其进行缓存以提高性能。这是由所提出的 API 可能实现的。

实现非常简单,因此我们将只显示 gf.x 函数:

gf.x = function(div,position) {
    if(position) {
        div.css("left", position);
        div.data("gf").x = position;
    } else {
        return div.data("gf").x; 
    }
}

分组

将游戏元素以分层方式组织起来非常方便。一个典型的游戏可以这样组织:

分组

为了允许这一点,我们需要向我们的框架添加一个非常简单的东西,称为组。组基本上是一个简单的 div,位置与精灵完全相同,但没有背景和宽度和高度。我们将添加一个 gf.addGroup 函数来为我们执行此操作。它的签名将与 gf.addSprite 的签名相同,但选项参数将仅保存 xy 坐标。

以下示例向您展示了如何生成前面图示中显示的树:

var enemies   = gf.addGroup(container,"enemies");
var enemy1    = gf.addSprite(group,"enemy1",{...});
var enemy2    = gf.addSprite(group,"enemy2",{...});

var player    = gf.addSprite(group,"player",{...});

var level     = gf.addGroup(container,"level");
var ground    = gf.addSprite(group,"ground",{...});
var obstacle1 = gf.addSprite(group,"obstacle1",{...});
var obstacle2 = gf.addSprite(group,"obstacle2",{...});

此功能的实现与 gf.addSprite 的实现非常相似:

gf.groupFragment = $("<div style='position: absolute; overflow: visible;'></div>");
gf.addGroup = function(parent, divId, options){
    var options = $.extend({
        x: 0,
        y: 0,
    }, options);
    var group = gf.groupFragment.clone().css({
            left:   options.x,
            top:    options.y}).attr("id",divId).data("gf",options);
    parent.append(group);
    return group;
}

在我们的游戏屏幕上有多个实体使得有一个简单的方法来区分它们成为必要。我们可以在通过 $.data 函数与节点关联的对象字面量中使用标志,但我们将改用 CSS 类。这有一个优点,就是可以非常容易地检索或过滤所有相同类型的元素。

要实现这一点,我们只需改变精灵和组的片段。我们将给 CSS 类命名为命名空间。在 CSS 中,命名空间简单地在类名中加上前缀。例如,我们将给我们的精灵添加类 gf_sprite;这将最大程度地减少另一个插件使用相同类的机会,与 sprite 相比。

新的片段看起来像这样:

gf.spriteFragment = $("<div class='gf_sprite' style='position: absolute; overflow: hidden;'></div>");
gf.groupFragment = $("<div class='gf_group' style='position: absolute; overflow: visible;'></div>");

现在,如果您想要查找所有子精灵,您可以这样写:

$("#someElement").children(".gf_sprite");

精灵变换

有许多情况下,您将希望以简单的方式转换您的精灵。例如,您可能希望使它们变大或变小,或者旋转或翻转它们。实现这一点的最方便的方法是使用 CSS 变换。在过去几年中,大多数浏览器都已很好地支持 CSS 变换。

如果您决定使用此功能,您只需意识到 Microsoft Internet Explorer 9 之前的版本不支持它。有可能使用专有的 filter CSS 属性,但在大多数情况下,这太慢了。

另一个可能性是使用一些旧的 8 位和 16 位游戏中使用的技术。您只需为变换后的精灵生成图像。这有很快的优势,并且与所有浏览器兼容。另一方面,它会增加您艺术品的大小,并且如果您需要在某个时候更改您的精灵,则需要重新生成所有的变换。

在这里,我们将仅实现 CSS 变换解决方案,因为在大多数情况下,仅针对现代浏览器是可接受的。

CSS 变换

CSS 有许多可能的变换,甚至是 3D 变换(您可以查看 github.com/boblemarin/Sprite3D.js 以获取一些非常好的示例),但我们将坚持旋转和缩放。

在大多数浏览器中,CSS 属性“transform”都是供应商前缀的。意思是,在 Safari 中,它将被称为-webkit-transform,而在 Firefox 中,将是-moz-transform。以往处理这类属性是一件真正痛苦的事情,但使用 jQuery 1.8,你可以简单地忘记它,就像没有前缀一样。jQuery 会在需要时自动使用正确的前缀。

正如之前所解释的,这个属性可以取许多值,我们将在这里专注于rotatescale两个:rotate的语法如下:

transform: rotate(angle)

在这里,angle是以degrad(分别缩写为度和弧度)表示的顺时针角度。旋转默认是围绕元素的原点进行的,大多数情况下,这是你希望的,但如果出于某种原因你想要改变它,你可以简单地使用transform-origin CSS 属性来实现。

例如,如果你想要逆时针旋转你的元素 10 度,你会写:

transform: rotate(-10deg);

如果你的元素是一个红色的正方形,它会像这样:

CSS transform

scale的工作方式非常相似,但具有两种可能的语法:

  • transform: scale(ratio)

  • transform: scale(ratio_x, ratio_y)

如果您只指定一个值,结果将是各向同性的变换;换句话说,沿着两个轴的大小是相等的。相反,如果你指定两个值,第一个将沿着 x 轴缩放,第二个将沿着 y 轴缩放(各向异性变换)。下图说明了这两者之间的区别。

CSS transform

在我们的情况下,我们将不包括任意的各向异性缩放到我们的框架中,但我们仍将使用双值语法,因为这将允许我们翻转我们的精灵;的确,如果我们写scale(-1,1),这实际上意味着“横向翻转元素并保持纵向不变”。当然,这对于除 1 之外的值也适用;只要两个值的大小相同,你只会翻转精灵而不改变其长宽比。

对于 transform 属性的这两个值,很好地配合在一起,所以如果你想要将一个元素逆时针旋转 10 度,垂直翻转它,并使其大小加倍,你会这样写:

transform: rotate(-10deg) scale(2,-2);

将 transform 添加到框架中

现在我们必须写一个函数来代替我们完成这个工作。与我们框架的大多数函数一样,我们将使用对象字面量来保存可选参数,并将函数应用于的节点作为第一个参数。调用这个函数来生成示例的示例为:

gf.transform (myDiv, {rotate: -10, scale: 2, flipV: true});

角度以度为单位,flipHflipV选项是布尔值。省略的参数的值(在本例中是flipH)将不会默认为常规值;相反,我们将采用给定元素的该参数的当前值。这将允许您两次调用变换函数并改变两个不同的参数,而无需知道另一个调用正在做什么。例如:

gf.transform (myDiv, {rotate: -10});
// do some other things
gf.transform (myDiv, {scale: 2, flipV: true});

然而,这意味着我们将无法像过去那样使用$.extend函数。相反,我们将不得不手动检查给定元素的未定义参数的存储值。

这些值将存储在与gf键关联的对象文字中,该键与具有$.data函数的元素相关联。这也意味着在创建精灵(或组)时,我们需要为这些属性定义默认值。例如,addSprite函数将以以下方式开始:

gf.addSprite = function(parent, divId, options){
    var options = $.extend({
        x: 0,
        y: 0,
        width: 64,
        height: 64,
        flipH: false,
      flipV: false,
      rotate: 0,
      scale: 1
    }, options);
//...

一旦你理解了 CSS transform属性的工作方式,实现我们的gf.transform函数将变得非常简单:

gf.transform = function(div, options){
   var gf = div.data("gf");
   if(options.flipH !== undefined){
      gf.flipH = options.flipH;
   }
   if(options.flipV !== undefined){
      gf.flipV = options.flipV;
   }
   if(options.rotate !== undefined){
      gf.rotate = options.rotate;
   }
   if(options.scale !== undefined){
      gf.scale = options.scale;
   }
   var factorH = gf.flipH ? -1 : 1;
   var factorV = gf.flipV ? -1 : 1;
   div.css("transform", "rotate("+gf.rotate+"deg) scale("+(gf.scale*factorH)+","+(gf.scale*factorV)+")");
}

再一次,这是一个简单的函数,会提供出色的功能,并允许我们在游戏中创建整洁的效果。根据你的游戏,你可能希望将各向异性缩放加入其中,甚至是 3D 转换,但函数的基本结构和 API 可以保持不变。

瓦片地图

瓦片地图是制作许多游戏的常用工具。其背后的理念是大多数关卡由类似的部分组成。例如,地面很可能会重复很多次,只是有少许变化;会有几种不同的树反复出现很多次,以及一些物品,如石头、花或草将以完全相同的精灵表示多次出现。

这意味着使用一个大图像来描述你的关卡并不是以空间为最有效的解决方案。你真正想要的是能够给出所有唯一元素的列表,然后描述它们如何组合生成你的关卡。

瓦片地图是最简单的实现方式。但是它增加了一个限制;所有元素都必须具有相同的大小并放置在网格上。如果你能够适应这些约束,这种解决方案将变得非常高效;这就是为什么那么多老游戏都是用它创建的原因。

我们将从实现一个非常天真的版本开始,然后在本章末尾展示如何在大多数情况下以不太多的工作快速实现它。

总而言之,瓦片地图由以下组成:

  • 一系列图片(我们在框架中称之为动画)

  • 一个描述图像放置位置的二维数组

以下图示说明了这一点:

瓦片地图

除了有助于减小游戏尺寸之外,瓦片地图还提供以下优点:

  • 检测与瓦片地图的碰撞非常容易。

  • 描述瓦片地图外观的数组还包含关于级别的语义信息。例如,瓦片 1 到 3 是地面瓦片,而 4 到 6 是景观的一部分。这将使您能够轻松“阅读”级别并对其做出反应。

  • 生成不同层次的随机变化非常简单。只需按照几条规则创建二维数组,每次玩家重新开始游戏时,游戏都会有所不同!

  • 存在许多开源工具可帮助您创建它们。

但是,您必须意识到也有一些约束:

  • 由于组成瓦片地图的所有元素大小相同,如果要创建更大的元素,则必须将其分解为较小的部分,这可能会很繁琐。

  • 即使具有很多才华,它也会给您的游戏带来一定的连续外观。如果您不想在级别周围重复一些块,则瓦片地图不适合您。

朴素的实现

我们已经知道如何创建精灵,所以基本上我们需要为创建瓦片地图生成组成它的精灵。就像gf.addSprite一样,我们的gf.addTilemap函数将接受父 div、生成的瓦片地图的 ID 以及描述选项的对象字面量。

选项是瓦片地图的位置、每个瓦片的尺寸、以及横向和纵向组成瓦片地图的瓦片数量、动画列表和描述瓦片位置的二维数组。

我们将遍历二维数组,并根据需要创建精灵。在我们的瓦片地图中有些地方没有精灵往往是很方便的,因此我们将使用以下约定:

  • 如果所有条目都是零,则意味着不需要在此位置创建精灵

  • 如果所有地方的数字都大于零,则表示应创建一个带有动画的精灵,该动画位于动画数组中对应此数字减一的索引处

这通常是您希望在将其添加到文档之前创建完整瓦片地图的地方。我们将使用克隆的片段来生成包含所有瓦片的div标签,并将我们用于精灵的克隆片段添加到其中。只有在创建所有瓦片后,我们才会将瓦片地图添加到文档中。

这里还有一个微妙之处。我们将向我们的瓦片添加两个类,一个标记瓦片所属的列,另一个标记瓦片所属的行。除此之外,目前代码中没有其他重要的细节:

gf.tilemapFragment = $("<div class='gf_tilemap' style='position: absolute'></div>");
gf.addTilemap = function(parent, divId, options){
    var options = $.extend({
        x: 0,
        y: 0,
        tileWidth: 64,
        tileHeight: 64,
        width: 0,
        height: 0,
        map: [],
        animations: []
    }, options);

    //create line and row fragment:
    var tilemap = gf.tilemapFragment.clone().attr("id",divId).data("gf",options);
    for (var i=0; i < options.height; i++){
        for(var j=0; j < options.width; j++) {
            var animationIndex = options.map[i][j];

            if(animationIndex > 0){
                var tileOptions = {
                    x: options.x + j*options.tileWidth,
                    y: options.y + i*options.tileHeight,
                    width: options.tileWidth,
                    height: options.tileHeight
                }
                var tile = gf.spriteFragment.clone().css({
                    left:   tileOptions.x,
                    top:    tileOptions.y,
                    width:  tileOptions.width,
                    height: tileOptions.height}
                ).addClass("gf_line_"+i).addClass("gf_column_"+j).data("gf", tileOptions);

                gf.setAnimation(tile, options.animations[animationIndex-1]);

                tilemap.append(tile);
            }
        }
    }
    parent.append(tilemap);
    return tilemap;
}

就这些了。这将在初始化时生成整个瓦片地图。这意味着非常大的瓦片地图会很慢。在本章末尾,我们将看到如何仅生成可见部分的瓦片地图。

碰撞检测

这是我们框架的一个非常重要的部分,我们将从精灵与瓦片地图碰撞的情况开始看看我们将如何做到这一点。这种情况有一个好处,即比一般情况更容易,但仍然使用了大部分相同的基本思想。然而,我们将坚持轴对齐元素。这意味着不会在此处显示与旋转元素的碰撞。

与瓦片地图碰撞

找到与精灵碰撞的瓦片地图的瓦片可以分为两部分。首先找到表示两者交集的框。然后,列出此框中的所有精灵。下图中以红色显示了一些可能的交叉点列表:

与瓦片地图碰撞

一开始可能会觉得复杂,但是如果你考虑到这与寻找两个一维交叉点(每个轴一个)完全相同的问题,那就会变得容易得多。

你可能没有意识到,在我们的青蛙过河克隆中,我们使用了一维交叉的简化版本来检测碰撞。下图显示了两个段,ab的典型一维交叉i的样子:

与瓦片地图碰撞

在这种情况下,交叉点只是第二个元素,因为它完全包含在第一个元素中。下图显示了另外三种可能的情况:

与瓦片地图碰撞

解决这个问题的一种方法是从第二个元素的角度来表达解决方案。两个点将定义区间;我们将最左边的点称为i1,最右边的点称为i2

首先考虑这样一个情况,即确实存在这样的交叉点,两个元素相互接触。您可能会发现i1a1b1之间的较大点。以同样的方式,i2a2b2之间的较小点。但是,如果两个区间不相交怎么办?如果区间a在其左侧,我们将简单地返回i1=b1i2=b1,如果区间a在其右侧,我们将返回i1=b2i2=b2。为了计算这个,我们只需要将i1i2的结果约束在b1b2之间。

结果函数如下所示:

gf.intersect = function(a1,a2,b1,b2){
    var i1 = Math.min(Math.max(b1, a1), b2);
    var i2 = Math.max(Math.min(b2, a2), b1);
    return [i1, i2];
}

好处是我们每个点只使用两次比较。现在我们可以将此应用于我们的二维问题。下图显示了如何将框交叉分解为两个线交叉点:

与瓦片地图碰撞

寻找碰撞瓦片

现在我们将编写一个函数,它接受一个精灵和一个瓦片地图。然后,它将为两个轴找到交叉点:x1 到 x2 和 y1 到 y2。现在点(x1,y1)将是交集框的左上角,点(x2,y2)将是右下角。

然而,我们在砖块地图中真正想要的不是坐标,而是二维数组中的索引。因此,我们将首先转换坐标,使原点是瓦片地图的左上角。然后,我们将根据单个瓦片的宽度和相应的高度来划分新坐标。在执行此操作的结果四舍五入后,我们将得到组成相交框的左上角和右下角瓦片的索引:

gf.tilemapBox = function(tilemapOptions, boxOptions){
    var tmX  = tilemapOptions.x;
    var tmXW = tilemapOptions.x + tilemapOptions.width * tilemapOptions.tileWidth;
    var tmY  = tilemapOptions.y;
    var tmYH = tilemapOptions.y + tilemapOptions.height * tilemapOptions.tileHeight;

    var bX  = boxOptions.x;
    var bXW = boxOptions.x + boxOptions.width;
    var bY  = boxOptions.y;
    var bYH = boxOptions.y + boxOptions.height;

    var x = gf.intersect(tmX,tmXW, bX, bXW);
    var y = gf.intersect(tmY, tmYH, bY, bYH);

    return {
        x1: Math.floor((x[0] - tilemapOptions.x) / tilemapOptions.tileWidth),
        y1: Math.floor((y[0] - tilemapOptions.y) / tilemapOptions.tileHeight),
        x2: Math.ceil((x[1] - tilemapOptions.x) / tilemapOptions.tileWidth),
        y2: Math.ceil((y[1] - tilemapOptions.y) / tilemapOptions.tileHeight)
    }
}

现在我们将在碰撞检测函数中使用这个结果。我们只需列出这两个点之间的所有瓦片。我们将使用二维数组来查找所有非零条目,然后使用我们为线和列定义的类来找到我们的瓦片。

gf.tilemapCollide = function(tilemap, box){
    var options = tilemap.data("gf");
    var collisionBox = gf.tilemapBox(options, box);
    var divs = []

    for (var i = collisionBox.y1; i < collisionBox.y2; i++){
        for (var j = collisionBox.x1; j < collisionBox.x2; j++){
            var index = options.map[i][j];
            if( index > 0){
                divs.push(tilemap.find(".gf_line_"+i+".gf_column_"+j));
            }
        }
    }
    return divs;
}

这将允许我们找到与精灵发生碰撞的所有瓦片,但我们必须确保我们为精灵和瓦片地图提供的坐标是正确的。如果精灵在一个向右移动了十个像素的组中,我们将不得不将十添加到精灵的 x 坐标值;否则,碰撞检测方法将不会注意到它。

我们可以编写一个版本的这个函数,它查看所有精灵和瓦片地图的坐标,以找出它们的相对偏移量。这会使函数稍微慢一些,稍微复杂一些,但你应该能够做到。

精灵与精灵的碰撞

用于检测两个精灵是否发生碰撞的函数将使用我们刚刚编写的同一维度交集函数。要使两个精灵发生碰撞,我们必须在两个一维投影上都发生碰撞。

如果 gf.intersect 函数返回的间隔长度为零(两个值相等),则表示这两个精灵在此轴上发生碰撞。要使两个精灵发生碰撞,两个投影都必须发生碰撞。

我们的函数实现非常简单,因为大部分逻辑都包含在 gf.intersect 函数中:

gf.spriteCollide = function(sprite1, sprite2){
   var option1 = sprite1.data("gf");
   var option2 = sprite2.data("gf");

   var x = gf.intersect(
      option1.x,
      option1.x + option1.width,
      option2.x,
      option2.x + option2.width);
   var y = gf.intersect(
      option1.y,
      option1.y + option1.height,
      option2.y,
      option2.y + option2.height);

   if (x[0] == x[1] || y[0] == y[1]){
      return false;
   } else {
      return true;
   }
}

编写游戏

我们现在有了开始游戏所需的所有工具。对于这个游戏,我们将使用 Kenney Vleugels 的精美艺术作品(www.kenney.nl)。这将是一个经典的平台游戏,玩家可以在其中移动和跳跃。

将有两种类型的敌人,一种是一种类似于斑点的物体,另一种是一种飞行昆虫。为了简单起见,玩家是不朽的,并在接触到敌人时将其杀死。我们将按以下顺序描述游戏的每个部分:

  • 游戏屏幕的基本设置

  • 用于玩家的面向对象代码

  • 玩家控制

  • 视差滚动

  • 敌人

游戏屏幕的基本设置

这与我们为 Frogger 克隆所做的非常相似。以下是我们将组织游戏屏幕的方式:

游戏屏幕的基本设置

在这个游戏中我们将有很多动画;玩家有三个,每个敌人有三个,两个背景动画有两个。为了使事情更加可读,我们将对它们进行分组。玩家和敌人的动画将分别存储在对象字面量中,而瓷砖的动画将存储在数组中。

这里是我们代码的一部分摘录:

var playerAnim = {
    stand: new gf.animation({
        url: "player.png",
        offset: 75
    }),
    walk:  new gf.animation({
        url:    "player.png",
        offset: 150,
        width:  75, 
        numberOfFrames: 10,
        rate: 90
    }),
    jump:  new gf.animation({
        url: "player.png",
        offset: 900
    })
};

var slimeAnim = {
   stand: new gf.animation({
        url: "slime.png"
    }),
    walk: new gf.animation({
        url: "slime.png",
        width:  43, 
        numberOfFrames: 2,
        rate: 90
    }),
    dead: new gf.animation({
        url: "slime.png",
        offset: 86
    })
};

var flyAnim = {
   stand: new gf.animation({
        url: "fly.png"
    }),
   ...
}
var tiles = [
    new gf.animation({
        url: "tiles.png"
    }),
    new gf.animation({
        url: "tiles.png",
        offset: 70
    }),
    ...
];

玩家的面向对象代码

在你的游戏中使用面向对象(OO)代码有许多原因。首先,这是组织代码的非常好的方式。其次,它提供了一些有用的方式来重用和扩展您的代码。

如果你不熟悉面向对象编程,JavaScript 可能不是学习的最佳语言。我们不会深入讨论 OO 的理论;即使没有,你也应该能够看到我们将要编写的代码背后的逻辑以及它带来了什么。

由于我们只需要一个玩家,我们将创建一个匿名类并立即实例化它。这相当不寻常,只在这种特殊情况下才有意义。这是我们类的框架,具有所有方法,但没有它们的实现。我们稍后将逐个查看它们。

var player = new (function(){
        var acceleration = 9;
        var speed = 20;
        var status = "stand";
        var horizontalMove = 0;

        this.update = function (delta) {
            //...
        };

        this.left = function (){
            //...
        };

        this.right = function (){
            //...
        };

        this.jump  = function (){
            //...
        };

        this.idle  = function (){
            //...
        };
});

正如你所看到的,我们首先定义了一些稍后将要使用的变量,然后定义了对象的方法。

更新玩家的位置

我们为玩家沿 y 轴的移动实现了一个非常基本的物理模拟;如果没有碰撞发生,头像将以给定的加速度和有限的最大速度下落。这足以生成整洁的跳跃轨迹。

让我们看看update函数做了什么。首先,它需要计算头像的下一个位置:

var delta = 30;
speed = Math.min(100,Math.max(-100,speed + acceleration * delta / 100.0)); 
var newY = gf.y(this.div) + speed * delta / 100.0;
var newX = gf.x(this.div) + horizontalMove;
var newW = gf.width(this.div);
var newH = gf.height(this.div);

在这段代码中,你可以看到我们计算了速度;这是玩家的垂直速度。我们在这里使用了正确的物理规则,即时间间隔后的速度等于前一个速度加上时间间隔的加速度。然后将其限制在-100 到 100 之间,以模拟终端速度。在这里,加速度是恒定的,重力也是如此。

然后我们使用这个速度来计算沿 y 轴的下一个位置,同样使用正确的物理规则。

沿 x 轴的新位置要简单得多;它是由玩家控制引起的水平移动修改后的当前位置(我们稍后将看到这个值是如何生成的)。

然后我们需要检查碰撞以查看头像是否真的可以去想去的地方,或者是否有障碍物。为此,我们将使用之前编写的gf.tilemapCollision方法。

一旦我们拥有所有与我们的精灵碰撞的瓷砖,我们可以做什么?我们将查看其中任何一个并通过最短可能的移动将精灵移出它们的路径。为此,我们将计算精灵与瓷砖之间的确切交叉点,并找出其宽度或高度哪个是其较大的尺寸。如果宽度大于高度,则意味着在 y 轴上移动较短,如果高度大于宽度,则在 x 轴上移动较短。

如果我们对所有瓷砖都这样做,我们将把角色移到一个不与任何瓷砖碰撞的位置。这是我们刚刚描述的全部代码:

var collisions = gf.tilemapCollide(tilemap, {x: newX, y: newY, width: newW, height: newH});
var i = 0;
while (i < collisions.length > 0) {
    var collision = collisions[i];
    i++;
    var collisionBox = {
        x1: gf.x(collision),
        y1: gf.y(collision),
        x2: gf.x(collision) + gf.width(collision),
        y2: gf.y(collision) + gf.height(collision)
    };

    var x = gf.intersect(newX, newX + newW, collisionBox.x1,collisionBox.x2);
    var y = gf.intersect(newY, newY + newH, collisionBox.y1,collisionBox.y2);

    var diffx = (x[0] === newX)? x[0]-x[1] : x[1]-x[0];
    var diffy = (y[0] === newY)? y[0]-y[1] : y[1]-y[0];
    if (Math.abs(diffx) > Math.abs(diffy)){
        // displace along the y axis
         newY -= diffy;
         speed = 0;
         if(status=="jump" && diffy > 0){
             status="stand";
             gf.setAnimation(this.div, playerAnim.stand);
         }
    } else {
        // displace along the x axis
        newX -= diffx;
    }
    //collisions = gf.tilemapCollide(tilemap, {x: newX, y: newY, width: newW, height: newH});
}
gf.x(this.div, newX);
gf.y(this.div, newY);
horizontalMove = 0;

你会注意到,如果我们检测到我们需要沿 y 轴向上移动玩家,我们会改变角色动画和状态,如果玩家正在跳跃,这仅仅是因为这意味着玩家已经着陆。

这段代码足以包含你在关卡中制作出一个体面的玩家移动所需的所有规则。

控制玩家的角色

除了update之外的所有方法都直接对应于玩家的特定输入类型。它们将在主循环中在相应的键被检测为按下后被调用。如果没有键被按下,将调用空闲函数。

让我们看一下将玩家向左移动的函数:

this.left = function (){
            switch (status) {
                case "stand":
                    gf.setAnimation(this.div, playerAnim.walk, true);
                    status = "walk";
                    horizontalMove -= 7;
                    break;
                case "jump":
                    horizontalMove -= 5;
                    break;
                case "walk":
                    horizontalMove -= 7;
                    break;
            }
            gf.transform(this.div, {flipH: true});
};

其主要部分是一个开关,因为我们将根据玩家的状态有不同的反应。如果玩家当前正在站立,我们将需要改变动画以行走,设置玩家的新状态,并沿 x 轴移动玩家。如果玩家正在跳跃,我们只是沿 x 轴移动玩家(但稍微慢一点)。如果玩家已经在行走,我们只需移动它。

最后一行水平翻转了精灵,因为我们的图像描述了面向右的玩家。向右的方向函数基本上是相同的。

jump方法将检查玩家当前是否处于站立或行走状态,如果是,则会更改动画,更改状态,并在update函数期间设置垂直速度以生成跳跃。

idle状态将将状态设置为站立,并相应地设置animation函数,但仅当玩家正在行走时。

this.jump  = function (){
    switch (status) {
        case "stand":
        case "walk":
            status = "jump";
            speed = -60;
            gf.setAnimation(this.div, playerAnim.jump);
            break;
    }
};

this.idle  = function (){
    switch (status) {
        case "walk":
            status = "stand";
            gf.setAnimation(this.div, playerAnim.stand);
            break;
    }
};

关于玩家移动就是这些。如果你仅仅使用这个对象中包含的逻辑开始游戏,你将已经拥有大部分构成平台游戏的东西——一个角色在各个平台之间移动跳跃。

玩家控制

我们仍然需要将玩家对象连接到主循环。这真的很简单,因为所有逻辑都包含在对象中。然而,我们忽略了一个小细节。由于玩家向左移动,他将离开屏幕。我们需要跟随他!我们将实现的方式如下:如果玩家超出了一个给定的点,我们将开始移动包含所有精灵和瓷砖的组,朝相反的方向移动。这会给人一种摄像机在跟随玩家的印象。

var gameLoop = function() {

    var idle = true;
    if(gf.keyboard[37]){ //left arrow
        player.left();
        idle = false;
    }
    if(gf.keyboard[38]){ //up arrow
        player.jump();
        idle = false;
    }
    if(gf.keyboard[39]){ //right arrow
        player.right();
        idle = false;
    }
    if(idle){
        player.idle();
    }

    player.update();
    var margin = 200;
    var playerPos = gf.x(player.div);
    if(playerPos > 200) {
        gf.x(group, 200 - playerPos);
    }
}

这是包含我们之前描述的所有内容的主循环。

视差滚动

视差滚动是给 2D 游戏增加一点深度的很好的方法。它利用了远离的物体看起来移动得越慢这一原理。这通常是当你从汽车的侧窗往外看到的景象。

视差滚动

在上图中的第一层将是包含所有精灵和平铺地图的组。第二层和第三层将简单地是图像。我们将使用与以前的游戏相同的技术:简单地使用背景位置来生成它们的移动。

最终的代码在主游戏循环中进行,就在我们移动组以保持玩家在屏幕上可见之后:

var margin = 200;
var playerPos = gf.x(player.div);
if(playerPos > 200) {
    gf.x(group, 200 - playerPos);
    $("#backgroundFront").css("background-position",""+(200 * 0.66 - playerPos * 0.66)+"px 0px");
    $("#backgroundBack").css("background-position",""+(200 * 0.33 - playerPos * 0.33)+"px 0px");
}

正如你所看到的,代码很简单;唯一微妙的地方在于选择每个图层速度的合适值。遗憾的是除了用赤裸裸的眼睛观察效果外,没有其他方法来做到这一点。

创建敌人

对于敌人,我们也将使用面向对象的代码。这将允许我们仅仅使用继承来指定两种敌人之间的不同之处。第一种是史莱姆。这种类型的敌人在地面上爬行,当它们死亡时,它们会被压扁并停留在它们被杀死的地方。它们在两点之间来回巡逻。

第二种是苍蝇。它们的行为与史莱姆完全相同,但它们在天空中飞行,一旦被杀死,就会坠入深渊。

我们将开始编写史莱姆的代码。它的结构与玩家的对象类似,但简单得多:

var Slime = function() {

   this.init = function(div, x1, x2, anim) {
      this.div = div;
      this.x1 = x1;
      this.x2 = x2;
      this.anim = anim;
      this.direction = 1;
      this.speed     = 5;
      this.dead      = false;

      gf.transform(div, {flipH: true});
      gf.setAnimation(div, anim.walk);
   };

   this.update = function(){
      if(this.dead){
         this.dies();
      } else {
         var position = gf.x(this.div);
         if (position < this.x1){
            this.direction = 1;
            gf.transform(this.div, {flipH: true});
         }
         if (position > this.x2){
            this.direction = -1;
            gf.transform(this.div, {flipH: false});
         }
         gf.x(this.div, gf.x(this.div) + this.direction * this.speed);
      }
   }
   this.kill = function(){
      this.dead = true;
      gf.setAnimation(this.div, this.anim.dead);
   }
   this.dies = function(){}
};

敌人只有两种状态,活着和死亡。这是update函数生成它们的行为,要么让它们巡逻,要么让它们死去。这里唯一的微妙之处在于我们使用一个方向变量来存储史莱姆是向左移动还是向右移动。

因为苍蝇的行为如此相似,我们不需要写太多来实现它们的对象:

var Fly = function() {}
Fly.prototype = new Slime();
Fly.prototype.dies = function(){
   gf.y(this.div, gf.y(this.div) + 5);
}

在这里,你可以看到 JavaScript 中对象继承的相当奇怪的语法(它被称为原型继承)。如果你对此不熟悉,你应该阅读一些关于 JavaScript 的高级书籍,因为这里发生的一切的全部意义超出了本书的范围。然而,直观理解它的方式是这样的:你创建一个简单的对象,并将另一个类的所有方法复制到它里面。然后你修改你想要覆盖的类。

这里我们真的只需要改变苍蝇死亡后的行为,让它坠落。

现在我们需要在主游戏循环中调用更新函数并检查与玩家的碰撞。同样,这样做的方式非常简单,因为大部分逻辑已经编写或者在框架中:

player.update();
for (var i = 0; i < enemies.length; i++){
   enemies[i].update();
   if (gf.spriteCollide(player.div, enemies[i].div)){
      enemies[i].kill();
   }
}

这就是我们的游戏。当然,就像上一个游戏一样,你可以在这里添加很多东西:让玩家有能力死亡,只有当他跳在敌人上时才允许他杀死敌人,或者任何你喜欢的东西。有了这个基本模板,你将能够根据你对基本规则的选择生成各种各样游戏玩法完全不同的游戏。这就是最终游戏的样子:

创建敌人

摘要

现在我们知道如何绘制瓦片地图以及检测它们和精灵之间以及精灵之间的碰撞。我们对于我们的游戏逻辑有一个可用的面向对象的代码的工作示例,我们将能够在许多其他类型的游戏中使用它。

至于我们之前的游戏,这里的游戏可以在许多方面进行改进,我建议这样做以更加熟悉代码。你可以增加更多的敌人,只有当玩家跳在它们上面时它们才会死亡,并且检测玩家何时到达关卡的结尾。

在下一章中,我们将运用我们在这里学到的技巧来制作一个俯视视角的 RPG 游戏。

第五章:视角透视

现在我们将看到如何渲染另一种非常流行的效果:俯视透视(也称为俯瞰透视)。可以使用这种技术创建各种不同的游戏:

  • 类似 大逃杀 的动作游戏

  • 类似 外星宝贝 的射击游戏

  • 类似于 塞尔达传说超时空战记 的 RPG

  • 类似于 模拟城市 的模拟

  • 类似于 文明魔兽争霸 的战争游戏

这些游戏使用的是所谓的正投影。可以使用简单的瓦片地图轻松渲染,就像我们在上一章中实现的那样。在本章中,我们将制作一个看起来像 塞尔达传说:超级任天堂时代 在超级任天堂上的角色扮演游戏。

我们将使用来自 BrowserQuest(browserquest.mozilla.org)的图形资产,这是 Mozilla 开发的非常酷的开源游戏,用于展示现代浏览器的能力。您可以在下面的截图中看到:

视角透视

在本章中,我们将涵盖以下主题:

  • 瓦片地图优化

  • 精灵层遮挡

  • 高级碰撞检测

在本章末尾,我们将快速讨论另一种可以用于相同类型游戏的俯视视图变体:2.5D 或等距投影。

优化俯视游戏的瓦片地图

我们在上一章实现的瓦片地图非常适合侧向滚动游戏,因为它们通常使用稀疏矩阵来定义它们的级别。这意味着如果你的级别长 100 个瓦片,高 7 个瓦片,那么它将包含远少于 700 个瓦片。这使我们能够在游戏开始时创建所有这些瓦片。

对于典型的俯视游戏,我们发现自己处于非常不同的情况。的确,为了渲染地图,定义了使用的瓦片地图的所有可能瓦片。这意味着对于相同尺寸的级别,我们将至少有 700 个瓦片。如果使用多个图层,情况会变得更糟。为了减少这个数字以提高性能,我们将只生成在启动时可见的瓦片。然后当视图移动时,我们将跟踪哪些瓦片变得不可见并删除它们,哪些瓦片变得可见并生成它们。

这里存在一个权衡;添加和删除瓦片会花费一些时间,而且很有可能会使游戏变慢。另一方面,在场景中有大量的瓦片并移动它们会使渲染变慢。

理想情况下,在两种技术之间做出选择是测试两种技术,找出哪种在目标平台上产生更好的结果。如果你真的需要,甚至可以使用混合方案,其中你按块生成瓦片地图。这将允许你调整何时容忍由于创建和删除瓦片而导致的减速。

在这里,我们将修改框架以仅显示可见的瓦片,并且已经证明对于这种玩家以合理速度移动且世界通常相当大的游戏来说,这已经足够快了。

查找可见瓦片

好消息是我们已经有了大部分需要找到可见瓦片的代码。实际上,我们有一个函数,它返回与一个框碰撞的瓦片。要找到可见瓦片,我们只需要将此框定义为游戏屏幕。

// find the visible part
var offset = gf.offset(parent);
var visible = gf.tilemapBox(options, {
       x:      -options.x - offset.x, 
       y:      -options.x - offset.y, 
       width:  gf.baseDiv.width(),
       height: gf.baseDiv.height()
});

在这里,您可以看到我们使用一个函数来找到瓦片地图的偏移量。这是必需的,因为它可能嵌套在一个或多个已移动的组中。

要找到偏移量,我们只需查看当前元素及其所有父元素。如果父元素不是精灵、组或瓦片地图,则会停止。如果父元素是基本的 div,即用于容纳整个游戏的 div,也会停止。

gf.offset = function(div){
   var options = div.data("gf");
   var x = options.x;
   var y = options.y;

   var parent = $(div.parent());
   options = parent.data("gf");
   while (!parent.is(gf.baseDiv) && options !== undefined){
      x += options.x;
      y += options.y;
      parent = $(parent.parent());
      options = parent.data("gf");
   }
   return {x: x, y: y};
}

要查找父元素是否为组、精灵或瓦片地图,我们检查与键“data”关联的对象是否存在。

除了找到可见框的部分之外,addTilemap函数本身并没有太多变化。以下是带有更改部分的其简短版本:

gf.addTilemap = function(parent, divId, options){
    var options = $.extend({
        x: 0,
        ...
    }, options);

    // find the visible part
 var offset = gf.offset(parent);
 var visible = gf.tilemapBox(options, {
 x:      -options.x - offset.x,
 y:      -options.x - offset.y,
 width:  gf.baseDiv.width(),
 height: gf.baseDiv.height()
 });
 options.visible = visible;

    //create line and row fragment:
    var tilemap = gf.tilemapFragment.clone().attr("id",divId).data("gf",options);
    for (var i=visible.y1; i < visible.y2; i++){
        for(var j=visible.x1; j < visible.x2; j++) {
            var animationIndex = options.map[i][j];

            ...
        }
    }
    parent.append(tilemap);
    return tilemap;
}

移动瓦片地图

现在我们必须跟踪瓦片地图的移动以更新哪些是可见的。由于我们有两个函数来移动任何元素,所以我们只需修改它们。

但是,我们不能只在瓦片地图移动时更新它们;当其任何父元素移动时,我们还必须更新它们。jQuery 提供了一种非常简单的方法来查找元素是否具有瓦片地图作为其子元素或孙子元素:.find()。此函数搜索与提供的选择器匹配的任何子元素。

由于我们将类gf_tilemap添加到每个瓦片地图中,因此检测它们非常容易。以下代码是带有更改的新gf.x函数。gf.y函数完全相同。

gf.x = function(div,position) {
    if(position !== undefined) {
        div.css("left", position);
        div.data("gf").x = position;

        // if the div is a tile map we need to update the visible part
        if(div.find(".gf_tilemap").size()>0){
 div.find(".gf_tilemap").each(function(){gf.updateVisibility($(this))});
 }
 if(div.hasClass("gf_tilemap")){
 gf.updateVisibility($(div));
 }
    } else {
        return div.data("gf").x; 
    }
}

如果子元素中的一个,或者元素本身,是瓦片地图,则需要更新它。我们使用gf.updateVisibility()函数来执行此操作。此函数仅在瓦片地图中找到新的可见框并将其与旧的框进行比较。这意味着我们必须将这种可见性存储在精灵的数据中。

下面的代码是此函数的完整实现:

gf.updateVisibility = function(div){
   var options = div.data("gf");
   var oldVisibility = options.visible;

    var parent = div.parent();

    var offset = gf.offset(div);
   var newVisibility = gf.tilemapBox(options, {
       x:      -offset.x,
       y:      -offset.y,
       width:  gf.baseDiv.width(),
       height: gf.baseDiv.height()
    });

    if( oldVisibility.x1 !== newVisibility.x1 ||
       oldVisibility.x2 !== newVisibility.x2 ||
       oldVisibility.y1 !== newVisibility.y1 ||
       oldVisibility.y2 !== newVisibility.y2){

       div.detach();

       // remove old tiles 
       for(var i = oldVisibility.y1; i < newVisibility.y1; i++){
          for (var j = oldVisibility.x1; j < oldVisibility.x2; j++){
             div.find(".gf_line_"+i+".gf_column_"+j).remove();
          }
       }
       for(var i = newVisibility.y2; i < oldVisibility.y2; i++){
          for (var j = oldVisibility.x1; j < oldVisibility.x2; j++){
             div.find(".gf_line_"+i+".gf_column_"+j).remove();
          }
       }
       for(var j = oldVisibility.x1; j < newVisibility.x1; j++){
          for(var i = oldVisibility.y1; i < oldVisibility.y2; i++){
             div.find(".gf_line_"+i+".gf_column_"+j).remove();
          }
       }
       for(var j = newVisibility.x2; j < oldVisibility.x2; j++){
          for(var i = oldVisibility.y1; i < oldVisibility.y2; i++){
             div.find(".gf_line_"+i+".gf_column_"+j).remove();
          }
       }
       // add new tiles

       for(var i = oldVisibility.y2; i < newVisibility.y2; i++){
          for (var j = oldVisibility.x1; j < oldVisibility.x2; j++){
             createTile(div,i,j,options);
          }
       }
       for(var i = newVisibility.y1; i < oldVisibility.y1; i++){
          for (var j = oldVisibility.x1; j < oldVisibility.x2; j++){
             createTile(div,i,j,options);
          }
       }
       for(var j = oldVisibility.x2; j < newVisibility.x2; j++){
          for(var i = oldVisibility.y1; i < oldVisibility.y2; i++){
             createTile(div,i,j,options);
          }
       }
       for(var j = newVisibility.x1; j < oldVisibility.x1; j++){
          for(var i = oldVisibility.y1; i < oldVisibility.y2; i++){
             createTile(div,i,j,options);
          }
       }
       div.appendTo(parent);

    }
    // update visibility
    options.visible = newVisibility;
}

前四个循环用于删除不再可见的现有瓦片。我们不再测试要删除的瓦片是否在顶部或底部,而是写两个循环。代码中的第一个循环写得好像要删除的瓦片在顶部。如果要删除的瓦片实际上在底部,如下图所示,该循环将不会执行,因为oldVisibility.y1 > newVisibility.y1

移动瓦片地图

如果砖块要从顶部、左侧或右侧删除,我们会使用相同的机制添加新的砖块。然而,有一件事情需要我们小心; 当我们先水平添加砖块时,当我们垂直添加它们时,我们必须确保不要再次创建我们已经创建的砖块。下图显示了重叠的砖块:

移动瓦片地图

有更加优雅的方法来实现这一点,但在这里,我们只是在创建砖块之前检查是否存在这样一个砖块。这是在gf.createTile函数中完成的。

var createTile = function(div, i,j,options){
   var animationIndex = options.map[i][j];
   if(animationIndex > 0 && div.find(".gf_line_"+i+".gf_column_"+j).size() === 0){
       var tileOptions = {
            x: options.x + j*options.tileWidth,
            y: options.y + i*options.tileHeight,
            width: options.tileWidth,
            height: options.tileHeight
        }
        var tile = gf.spriteFragment.clone().css({
            left:   tileOptions.x,
            top:    tileOptions.y,
            width:  tileOptions.width,
            height: tileOptions.height}
        ).addClass("gf_line_"+i).addClass("gf_column_"+j).data("gf", tileOptions);

        gf.setAnimation(tile, options.animations[animationIndex-1]);

        div.append(tile);
    }
}

有了这两个改变,瓦片地图现在是动态生成的。

排序遮挡

在使用俯视视图时,我们将遇到两种可能性中的一种: 要么“摄像机”直接望向地面,要么呈轻微的角度。下图说明了这两种情况:

排序遮挡

在第一种情况下,唯一一个元素被另一个元素隐藏的情况是它直接在上面。要产生这种效果非常容易;我们只需为每个高度使用一个组,并将精灵和瓦片地图放入正确的组中。

例如,让我们考虑一个包含树和桥的关卡,玩家可以在桥下行走,就像下图中的情况:

排序遮挡

我们可以像这样组织我们的游戏屏幕:

排序遮挡

一旦完成这个步骤,就没什么好担心的了。如果 NPC(非玩家角色)或玩家在某个时刻上下移动,我们只需将他们从一个组中移除并添加到另一个组中。

然而,大多数现代游戏使用第二种视角,并且这也是我们小游戏将使用的视角。在这种透视中,不仅是上面的元素会覆盖其他元素,前面的元素也可能会隐藏它们。下图说明了这一点:

排序遮挡

为了想出一个严格通用的解决方案对于大多数游戏来说可能有点过度,而且可能会产生一些性能问题。相反,我们将使用以下技巧来产生令人信服的效果。

精灵屏蔽

如果我们做出以下假设,精灵的情况就会变得简单:

  • 地面是完全平坦的。可能有许多不同高度的平坦“楼层”,但它们每一个都是平坦的。

  • 两个平坦楼层之间的高度差大于最大 NPC 或玩家的尺寸。

通过这些限制,我们可以用以下两个规则管理精灵的遮挡:

  • 如果一个精灵在比另一个更高的楼层上,则前者将始终隐藏后者。

  • 如果两个精灵在同一楼层上,则 y 坐标较大的那个将始终隐藏另一个

实现这个最简单的方式是使用z-index CSS 属性。实现看起来会像这样:

gf.y(this.div, y);
this.div.css("z-index", y + spriteHeight);

这里我们需要将精灵的高度加到 y 坐标上,因为我们需要考虑的是遮挡的底部而不是顶部。

如果精灵所在的楼层高一层,我们将确保其 z 索引大于上方所有楼层中的所有精灵。假设我们给每个层级分配一个索引,0 表示最低层,1 表示上方的一层,依此类推;在这种情况下,从 y 坐标生成 z 索引的公式将是:

z-index = y-coordinate + spriteHeight + floorIndex * floorHeight

在我们的游戏中,所有的精灵都将处于同一水平线上,因此我们不需要使用这个函数,而且我们可以坚持使用之前的代码。

关卡与精灵的遮挡

如果我们仍然保持之前的假设,那么我们不需要做太多工作来从背景中生成精灵的遮挡。我们的关卡是使用瓦片地图定义的。在设计关卡时,我们将我们的瓦片分成两个瓦片地图:一个是地板,另一个是地板上方的所有内容。

举例来说,让我们考虑一个场景,有一棵树和一座房子:

关卡与精灵的遮挡

我们将地面、房子底部和树干存储在一个瓦片地图中,而将房顶和树叶存储在另一个瓦片地图中。

碰撞检测

对于此游戏,碰撞检测与之前的游戏略有不同。由于我们使用的是碰撞而不是与精灵边界框的每个像素的碰撞,我们可能会出现仅有精灵的非透明像素发生碰撞的情况,如下图所示:

碰撞检测

然而,有一个非常简单的解决方案来解决这个问题,而不需要使用每个像素或多边形碰撞检测;我们将使用第二个透明精灵来创建我们真正想要用于碰撞检测的区域。

玩家与环境的碰撞

在我们的游戏中,我们将使用 RPG 中经常使用的一种技术;玩家角色将不仅由一个精灵组成,而是由多个精灵叠加而成。这将使我们能够更改角色所穿的盔甲、使用的武器、发型、肤色等,而无需生成所有可能的组合变体。

在我们的游戏中,玩家角色的头像只会使用两张图片:玩家本身和其武器。我们会将它们放入一个组中;这样可以轻松地移动它们。

对于这两个精灵,我们首先会添加一个透明精灵,用于定义与环境碰撞的碰撞区域。下图正是显示了这一点:

玩家与环境的碰撞

正如你所看到的,我们选择了一个碰撞框,其宽度与玩家角色的身体一样宽,但稍微短一些。这是为了考虑到玩家从下方靠近障碍物的情况。如前图所示,他的头部将隐藏该物体的底部的一部分。通过这个较小的碰撞框,我们自动产生了这种效果。

现在我们不希望角色与级别中的每个元素发生碰撞。例如,它不应该与地面或地面上方的任何东西发生碰撞。

如果你记得,我们之前将级别分成了两个瓦片地图。为了更容易进行碰撞检测,我们将简单地将下面的一个分成两个:

  • 包含所有与玩家不发生碰撞的地面元素

  • 包含所有与玩家碰撞的元素

这意味着我们现在有三个级别的瓦片地图。

正如你可以想象的,设计这个级别并将所有瓦片添加到正确的瓦片地图中正在变得过于复杂,因为我们手工编写了所有数组。相反,我们将使用瓦片地图编辑器。

使用瓦片地图编辑器

有相当多的免费和开源的瓦片地图编辑器。对于这个游戏,我们将使用 Tiled (www.mapeditor.org/)。它的优点是可以将瓦片地图导出为 JSON 文件。

我们将用于创建级别的图像来自 Mozilla 的游戏 BrowserQuest。以下图片显示了其中的一部分:

使用瓦片地图编辑器

正如你所见,我们有草地的瓦片,沙地的瓦片,以及代表向沙地过渡的瓦片。过渡瓦片是半透明的,一半是沙地。这样可以让我们从任何其他类型的地面过渡到沙地。

这意味着我们将不得不使用另一个瓦片地图。下面的瓦片地图将分成两部分:一个包含所有地面元素,一个包含透明像素且不与玩家发生碰撞的过渡元素。但是,总共我们将有四个瓦片地图来绘制我们的级别。例如,我们级别的一部分带有沙子、草地和一棵树会是这样的:

使用瓦片地图编辑器

我们不会查看导入 Tiled 生成的 JSON 文件的整个代码。如果你想了解更多细节,只需查看gf.importTiled函数。重要的部分是我们使用 jQuery 的$.ajax函数。使用这个函数,我们将能够加载 JSON 文件。诀窍是使用正确的参数来调用它:

$.ajax({
   url: url,
   async: false,
   dataType: 'json',
   success: function(json){...}
);

jQuery 还提供了一个名为$.getJSON的简写函数,但是我们希望进行同步调用,这只有通过$.ajax才可能。使用这些调用,我们提供给成功参数的函数将在 JSON 文件加载完成后调用。就在这个函数中,我们将导入文件。

如果你想看看我们究竟是如何做到的,你只需简单地查看本章提供的代码即可。

现在我们正在使用$.ajax函数,我们只需确保从服务器访问我们的代码以测试它,因为简单地在浏览器中打开我们的 HTML 文件将不再起作用。如果你没有运行服务器,你可以在 Windows 上使用 EasyPHP (www.easyphp.org),或者在 OS X 上使用 MAMP (www.mamp.info)。

玩家与精灵的碰撞

我们只支持一种精灵与精灵碰撞检测:玩家攻击敌人或与 NPC 交谈。和以前一样,我们需要一个透明精灵来定义应该检测到碰撞的区域。但是这次,这个区域不在玩家身上,而是在他前面,如下面的截图所示:

玩家与精灵碰撞

唯一的技巧是这个区域必须四处移动,以始终面向玩家所看的方向。如果我们使用上一个游戏中用来实现玩家的相同 OO 代码,它看起来会像这样:

var player = new (function(){
    // the group holding both the player sprite and the weapon
    this.div = $();
    // the sprite holding the player's avatar
    this.avatar = $();
    // the sprite holding the weapon
    this.weapon = $();
    // the hit zone
    this.hitzone  = $();
    // collision zone
    this.colzone = $();

    //...

    this.update = function () {
        //...
    };

    this.left = function (){
        if(state !== "strike"){
            if(orientation !== "left" && moveY === 0 && moveX === 0){
                orientation = "left";
                gf.x(this.hitzone, 16);
                gf.y(this.hitzone, 16);
                gf.h(this.hitzone,  128 + 32);
                gf.w(this.hitzone, 64);
                //...

            }
            //...
        }
    };

    this.right = function (){
        //...
    };

    this.up = function (){
        //...
    };

    this.down = function (){
        if(state !== "strike"){
            if(orientation !== "down" && moveY === 0 && moveX === 0) {
                orientation = "down";
                state = "walk";
                gf.x(this.hitzone, 16);
                gf.y(this.hitzone, 192-80);
                gf.w(this.hitzone,  128 + 32);
                gf.h(this.hitzone, 64);
                //...
            }
            //...
        }
    };

    //...
});

代码的突出显示部分显示了我们在与 NPC 和敌人的交互中更改碰撞区域位置的地方。我们称之为精灵命中区,因为它代表了玩家剑挥动覆盖的区域。

要为这个击中区选择正确的大小和位置,你确实必须对你使用的图像进行微调。

在主游戏循环中,我们将检查此区域与 NPC 列表和敌人之间的碰撞,然后。

this.detectInteraction = function(npcs, enemies, console){
    if(state == "strike" && !interacted){
        for (var i = 0; i < npcs.length; i++){
            if(gf.spriteCollide(this.hitzone, npcs[i].div)){
                npcs[i].object.dialog();
                interacted = true;
                return;
            }
        }
        for (var i = 0; i < enemies.length; i++){
            if(gf.spriteCollide(this.hitzone, enemies[i].div)){
                // handle combat
                interacted = true;
                return;
            }
        }
    }
};

与 NPC 交谈

我们将实现与 NPC 的唯一交互是单向对话。当玩家击中 NPC 时,我们将显示一行对话。如果他再次击中它,并且 NPC 还有更多话要说,我们将显示下一行对话。

我们将在屏幕底部使用一行来显示这个文本。这行必须是半透明的,以便让玩家看到其背后的关卡,并且必须覆盖游戏的所有元素。这是我们将创建它的方法:

container.append("<div id='console' style='font-family: \"Press Start 2P\", cursive; color: #fff; width: 770px; height: 20px; padding: 15px; position: absolute; bottom: 0; background: rgba(0,0,0,0.5); z-index: 3000'>");

这种类型的界面通常称为控制台。为了使其半透明,同时保留其中的文本不透明,我们通过调用rgba()函数应用透明的背景颜色。为了确保它浮在所有游戏元素的上方,我们给它一个足够大的 z 索引。

要在此控制台中显示文本,我们只需使用.html()。以下代码是 NPC 的完整实现:

var NPC = function(name, text, console){
    var current = 0;

    this.getText = function(){
        if(current === text.length){
            current = 0;
            return "[end]";
        }
        return name + ": " + text[current++];
    };

    this.dialog = function(){
        console.html(this.getText());
    }
}

这是我们将实例化其中一个的方法:

npcs.push({
    div: gf.addSprite(npcsGroup,"NPC1", {
        x:      800,
        y:      800,
        width:  96,
        height: 96
    }),
    object: new NPC("Dr. Where", ["Welcome to this small universe...","I hope you will enjoy it.","You should head east from here...","there's someone you may want to meet."], console)
});
npcs[npcs.length-1].object.div = npcs[npcs.length-1].div;
gf.setAnimation(npcs[npcs.length-1].div, new gf.animation({
    url: "npc/scientist.png"
}));
$("#NPC1").css("z-index",800 + 96);

这里没有什么特别的;我们只需确保设置正确的 z 索引即可。

与敌人战斗

要与敌人战斗,我们将模拟掷骰子。战斗规则在 RPG 中非常典型:玩家向玩家掷骰子,并将其加到一个称为攻击修正值的固定值上。这将生成玩家攻击的攻击值。敌人将试图通过向敌人掷骰子并将其加到自己的防御修正值上来进行防御。

如果玩家的攻击大于敌人的防御,攻击就成功了,敌人将受到等于玩家攻击的生命损失。如果敌人的防御更强,攻击将失败,敌人将保持安全。

以下代码是此机制的实现:

if(gf.spriteCollide(this.hitzone, enemies[i].div)){
    var enemyRoll = enemies[i].object.defend();
    var playerRoll = Math.round(Math.random() * 6) + 5;

    if(enemyRoll <= playerRoll){
        var dead = enemies[i].object.kill(playerRoll);
        console.html("You hit the enemy "+playerRoll+"pt");
        if (dead) {
            console.html("You killed the enemy!");
            enemies[i].div.fadeOut(2000, function(){
                $(this).remove();
            });
            enemies.splice(i,1);
        }
    } else {
        console.html("The enemy countered your attack");
    }
    interacted = true;
    return;
}

在这里,我们使用控制台向玩家显示战斗的进展情况。战斗的公式可能会因额外的参数而不同,例如玩家使用的武器提供的奖励以及敌人的盔甲。当决定一次打击是否成功时,真的取决于你要考虑的因素。

我们没有实现这个,但是敌人的反击会完全相同。

完整的游戏

游戏就到此为止了。其余的所有实现都直接来自我们在第四章中创建的游戏,《横向观察》。我们使用相同的面向对象的代码来解决玩家和其他精灵之间的碰撞。

一个很好的练习是让敌人四处移动并攻击玩家,为玩家实现一个经验和生命条,并设计一个更大的世界和更多的 NPC,使故事更加有趣。事实上,这就是编写 RPG 游戏的伟大之处;它们是讲故事的绝佳媒介!

另一种你可以改进这个游戏的方式是使用等距投影而不是正交投影。解释如何编写一个通用的等距引擎不在本书的范围内,但如果你想了解更多,你可以阅读Andres Pagella的《使用 HTML5、CSS3 和 JavaScript 制作等距社交实时游戏》(shop.oreilly.com/product/0636920020011.do)。

等距瓷砖

处理等距瓷砖时存在两个困难。首先,使用 DOM 元素显示正交网格非常简单,而使用等距网格更加复杂。其次,遮挡计算更加困难。

绘制等距瓷砖地图

我们将在这里使用一个技巧来生成我们的瓷砖地图。我们的每个瓷砖都将存储在一个区域,周围都是透明像素,以便给它们一个方形的形状,就像以下的屏幕截图一样:

绘制等距瓷砖地图

要实现这种魔法效果,我们将使用两个普通的瓷砖地图来显示一个等距瓷砖地图。它们会重叠,但它们之间的偏移量等于一个瓷砖的高度和宽度的一半。下图展示了它会是什么样子:

绘制等距瓷砖地图

等距游戏的遮挡

对于等距游戏来说,遮挡比正交游戏更难管理。在这种情况下,你不能简单地通过图层来生成正确的遮挡。相反,你将不得不给在关卡中定位的每个“块”(如墙壁、树木、物体等)赋予一个 z 索引。

这种遮挡的价值将取决于其坐标,就像之前的玩家、NPC 和敌人一样。这意味着你需要对瓦片地图进行后处理并生成它们。这个过程可能非常复杂,难以自动化,如果你的游戏元素数量相对较小,你可能会选择手动完成。否则,你将需要一些关于每个块位于何处的 3D 模型。

摘要

在本章中,你已经学会了如何充分利用瓦片地图。现在你可以使用本章和上一章学到的技术来编写各种各样的游戏。你可能会发现,在编写游戏时遇到的问题往往是相同的。然而,最佳解决方案往往取决于你游戏的限制和约束。

当你开始编写游戏时,不要试图实现通用解决方案,而是首先专注于你的特定情况。结果很可能会更快、更容易维护,并且实现起来会花费更少的时间。

在下一章中,我们将学习如何使用我们在第四章 横向看 中创建的平台游戏来实现多层游戏。

第六章:向你的游戏添加关卡

到目前为止,我们所有的游戏都只有一个关卡。这对于演示或概念验证来说很好,但你可能希望在游戏中有很多关卡。和往常一样,有很多方法可以做到这一点,但其中大多数都基于这样一个想法:每个关卡都由它们自己的文件(或文件)描述。

我们将在本章开始时快速探讨不同的文件组合方式来创建你的游戏。然后我们将查看允许这种技术的 jQuery 函数。

最后,我们将把我们在第四章中开发的游戏,横向查看,扩展到包括三个关卡,通过实现之前描述的一些技术。

以下是本章我们将涵盖的主题的快速列表:

  • 使用多个文件来构建你的游戏

  • 使用 $.ajax 加载文件

  • 执行远程 JavaScript

  • 向我们的游戏添加新关卡

实现多文件游戏

你首先要问自己的问题是,“其他文件何时加载?” 传统的方法是有简单的关卡,并在前一个关卡结束时加载下一个。这是平台游戏的典型情景。

另一种方法是有一个大的关卡,并在到达给定点时加载子关卡。通常,在 RPG 中,大关卡将是外部世界,而子关卡将是建筑物内部。在这两个示例中,文件的加载不需要异步执行。

最后一个常见的方法是拥有一个由许多子关卡组成的单个非常大的关卡。这通常是 MMORPG 的情况。在这里,你需要异步加载文件,以便玩家不会注意到必须加载子关卡。

你将面临的挑战取决于你处于上述哪种情况。它们可以分为以下几类:加载瓦片地图、精灵、加载逻辑行为。

加载瓦片地图

如果你还记得,在第五章中,透视,我们加载了以 JSON 文件形式的瓦片地图。正如我们之前解释的那样,我们加载一个包含瓦片地图描述的 JSON 文件。为此,我们使用 jQuery 中的基本 AJAX 函数:$.ajax()。稍后我们将看到如何使用此函数的所有细节。

然而,仅仅加载瓦片地图通常不足以完全描述你的关卡。你可能想要指定关卡结束的位置,哪些区域会杀死玩家,等等。一种常见的技术是使用第二个瓦片地图,一个不可见的瓦片地图,它包含为另一个瓦片地图添加含义的瓦片。

以下图示是一个示例:

加载瓦片地图

这有几个优点:

  • 你可以轻松地给不同的瓦片赋予相同的语义含义。例如,有或没有草的瓦片可以表示地面,并且与玩家的交互方式完全相同。

  • 您可以为使用完全不同瓦片集的两个级别的瓦片赋予相同的语义含义。这样,只要它们使用相同的逻辑瓦片来建模,您就不必真正担心在您的级别中使用了什么图像。

实现这并不是真正困难的。下面的代码显示了gf.addTilemap函数的更改:

gf.addTilemap = function(parent, divId, options){
    var options = $.extend({
        x: 0,
        y: 0,
        tileWidth: 64,
        tileHeight: 64,
        width: 0,
        height: 0,
        map: [],
        animations: [],
        logic: false
    }, options);

    var tilemap = gf.tilemapFragment.clone().attr("id",divId).data("gf",options);

    if (!options.logic){

       // find the visible part
       var offset = gf.offset(parent);
       var visible = gf.tilemapBox(options, {
          x:      -options.x - offset.x,
          y:      -options.x - offset.y,
          width:  gf.baseDiv.width(),
          height: gf.baseDiv.height()
       });
         options.visible = visible;

       //create line and row fragment:
       for (var i=visible.y1; i < visible.y2; i++){
           for(var j=visible.x1; j < visible.x2; j++) {
               var animationIndex = options.map[i][j];

               if(animationIndex > 0){
                   var tileOptions = {
                       x: options.x + j*options.tileWidth,
                       y: options.y + i*options.tileHeight,
                       width: options.tileWidth,
                       height: options.tileHeight
                   }
                   var tile = gf.spriteFragment.clone().css({
                       left:   tileOptions.x,
                       top:    tileOptions.y,
                       width:  tileOptions.width,
                       height: tileOptions.height}
                   ).addClass("gf_line_"+i).addClass("gf_column_"+j).data("gf", tileOptions);

                   gf.setAnimation(tile, options.animations[animationIndex-1]);

                   tilemap.append(tile);
               }
           }
       }
    }
    parent.append(tilemap);
    return tilemap;
}

如您所见,我们只是添加了一个标志来指示瓦片地图是否是为了逻辑目的。如果是这样,我们就不需要在其中创建任何瓦片。

碰撞检测函数现在也略有修改。在逻辑瓦片地图的情况下,我们不能简单地返回 divs。相反,我们将返回一个包含碰撞瓦片的大小、位置和类型的对象文字。下面的代码片段显示了这一点:

gf.tilemapCollide = function(tilemap, box){
    var options = tilemap.data("gf");
    var collisionBox = gf.tilemapBox(options, box);
    var divs = []

    for (var i = collisionBox.y1; i < collisionBox.y2; i++){
        for (var j = collisionBox.x1; j < collisionBox.x2; j++){
            var index = options.map[i][j];
            if( index > 0){
               if(options.logic) {
 divs.push({
 type:   index,
 x:      j*options.tileWidth,
 y:      i*options.tileHeight,
 width:  options.tileWidth,
 height: options.tileHeight
 });
 } else {
                   divs.push(tilemap.find(".gf_line_"+i+".gf_column_"+j));
             }
            }
        }
    }
    return divs;
}

一旦实现了这个功能,加载关卡就变得非常容易了。事实上,只要逻辑瓦片地图存在并且游戏代码知道如何对每个瓦片做出反应,我们就不需要任何额外的东西让玩家对其环境做出反应。

加载精灵及其行为

如果从不同文件加载瓦片地图相当简单,那么对于关卡包含的精灵,有很多方法可以做同样的事情。

您可以为一个 JSON 文件实现一个解释器,该解释器将依次创建和配置敌人和 NPC。这样做的好处是,您可以合并这个 JSON 和描述瓦片地图的 JSON。这样您只需要加载一个文件而不是两个文件。由于每个加载的文件都有相当大的开销,因此文件的大小几乎没有影响;在大多数情况下,它将使您的关卡加载更快。下图说明了这一点:

加载精灵及其行为

它也有一些缺点:首先,您的引擎必须被编写成理解您希望您的敌人采取的所有可能行为。这意味着,如果您有一种仅在游戏的第十关中使用的敌人,您仍然需要在启动时加载其实现。如果您在一个团队中工作,其他成员想要实现自己类型的敌人,他们将需要修改引擎而不仅仅是在他们的关卡上工作。

您还需要非常小心地指定一个涵盖所有需求的 JSON 格式,否则您将有可能在以后不得不重构游戏的大部分内容。下面的代码是这样一个 JSON 文件的示例:

{
   "enemies" : [
      {
         "name" : "Monster1",
         "type" : "spider",
         "positionx" : 213,
         "positiony" : 11,
         "pathx" : [250,300,213],
         "pathy" : [30,11,11]
      },
      {
         "name" : "Monster2",
         "type" : "fly",
         "positionx" : 345,
         "positiony" : 100,
         "pathx" : [12,345],
         "pathy" : [100,100]
      }   
   ],
   "npcs" : [
      {
         "name" : "Johny",
         "type" : "farmer",
         "positionx" : 202,
         "positiony" : 104,
         "dialog" : [
            "Hi, welcome to my home,",
            "Feel free to wander around!"
         ]
      }
   ]
}

另一种可能的实现是加载一个完整的脚本,该脚本将依次创建敌人并配置它们。这样做的好处是使您的游戏更具模块化,并减少了游戏与关卡之间的耦合。

虽然它有一些缺点。首先,如果你不小心,你的级别代码有可能覆盖一些主要游戏变量。这将创建相当难以跟踪的错误,并且将依赖于级别加载的顺序。其次,你必须特别小心地选择你的变量范围,因为每次加载新级别的代码都是在全局范围内执行的。

在本章中给出的示例中,我们将选择第二种解决方案,因为对于一个小游戏来说,这是比较合理且相当灵活的。

无论你选择实现哪一个,你很可能将使用$.ajax或其别名之一。在下一节中,我们将对其进行详细介绍。

使用$.ajax

$.ajax函数是一个非常强大但低级的函数。它有许多别名,可用于不同的特定任务:

  • $.get是一个多用途别名,与$.ajax相比减少了选项的数量,并且其 API 是基于多个可选参数而不是单个对象文字。它总是以异步方式加载文件。

  • $.getJSON是一个用于异步加载 JSON 文件的函数。

  • $.getScript是一个以异步方式加载脚本并且执行的函数。

  • $.load是一个以异步方式加载 HTML 文件并将其内容注入到所选元素中的函数。

  • $.post类似于$.get,但使用了 post 请求。

如你所见,所有这些别名都有一个共同点:它们都以异步方式加载它们的文件。这意味着如果您更喜欢同步加载资源,您将需要使用$.ajax。但是,一旦您知道了正确的参数,你稍后会看到它实际上并没有比别名更复杂。此外,别名的 API 文档始终包括要用于$.ajax调用的确切参数以产生相同的效果。

当使用$.ajax时,你必须确保通过服务器访问文件,并且遵守同源策略。否则,在大多数浏览器上,你可能会遇到问题。要了解有关$.ajax的更多信息,您应该查看官方 jQuery API 文档(api.jquery.com/jQuery.ajax/)。

加载一个 JSON 文件

JSON 文件是一种非常方便的加载外部数据的方式,无需自行解析。一旦加载,JSON 文件通常存储在一个简单的 JavaScript 对象中。然后你只需查找其属性就能访问数据。

如果你想用$.ajax模拟对$.getJSON的调用,它看起来会像下面的代码:

$.ajax({
  url: url,
  dataType: 'json',
  data: data,
  success: callback
});

在这里,url是 JSON 文件的 Web 地址,data是您可能希望传递到服务器的可选参数列表,success是在加载 JSON 文件后将处理它的回调函数。如果你想同步访问远程文件,你必须在调用中添加参数async : false

它是在回调函数中你将决定如何处理 JSON 文件;它将具有以下签名:

var callback = success(data, textStatus, jqXHR)

在这里,data保存着从 JSON 文件生成的对象。你将如何处理它,这实际上取决于你的用例;这里是一个导入 Tiled 生成的地图图块的代码的简短版本:

success: function(json){
    //...

   var layers = json.layers;
   var usedTiles = [];
   var animationCounter = 0;
   var tilemapArrays = [];

   // Detect which animations we need to generate
   // and convert the tiles array indexes to the new ones
   for (var i=0; i < layers.length; i++){
      if(layers[i].type === "tilelayer"){
         // ...
         tilemapArrays.push(tilemapArray);
      }
   }
   // adding the tilemaps
   for (var i=0; i<tilemapArrays.length; i++){
      tilemaps.push(gf.addTilemap(parent, divIdPrefix+i, {
         x:          0,
         y:          0,
         tileWidth:  tileWidth,
         tileHeight: tileHeight,
         width:      width,
         height:     height,
         map:        tilemapArrays[i],
         animations: animations,
         logic: (layers[i].name === "logic")
         }));
      }
   }
});

高亮部分是相当典型的。实际上,大多数复杂的 JSON 都将包含一系列元素,以便描述任意数量的类似实体。当您不是 JSON 文件规范的设计者时,您可能会发现自己处于这样一种情况:您必须将 JSON 对象的内容转换为自己的数据结构。这段代码正是这样做的。

这里没有通用的方法,你真的必须考虑每种情况。好处是,在大多数情况下,这段代码只会在游戏中执行几次,因此,性能方面并不敏感。与其在所有地方搜索可以使其运行更快的地方,还不如使其尽可能易读。

加载远程脚本

如果你想要用$.ajax来模仿$.getScript的用法,看起来会像下面这样:

$.ajax({
  url: url,
  dataType: "script",
  success: success
});

就像我们之前做的那样,你可以通过简单地将async : false添加到参数列表中使其同步。这将做两件事情:加载脚本并执行它。这里回调函数并不那么重要,它只允许你跟踪文件是否成功检索。

正如前面提到的,脚本将在全局范围内执行。这对你的代码组织有一些影响。直到现在,我们游戏的代码看起来像这样:

$(function() {
    var someVariable = "someValue";

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

所有的函数和变量都在一个"私有"范围内定义,外部无法访问。这意味着如果你的远程代码尝试做下面这样的事情,它将失败:

var myVariable = someVariable;
someFunction();

实际上,函数someFunctionsomeVariable在全局范围内是不可见的。解决方案是仔细选择哪些变量和函数应该对远程代码可见,并将它们放在全局范围内。在我们的情况下,可能会像这样:

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

$(function() {
    // do something else
});

你可能想要将所有这些函数都放在一个命名空间中,就像我们为我们的框架所做的那样。由于你正在编写一个最终产品,不太可能被其他库用作库,这更多取决于个人偏好。

调试对$.ajax 的调用

现在我们正在加载远程文件,可能会出现一系列新问题:文件的 URL 可能不再有效,服务器可能已经关闭,或者文件可能格式不正确。在生产环境中,您可能希望在运行时检测这些问题,以向用户显示一条消息,而不仅仅是崩溃。在开发阶段,您可能希望找出到底出了什么问题,以便调试您的代码。

jQuery 提供了三个函数,你可以用它们来实现这个功能:.done().fail().always()。以前还有另外三个函数(.success().error().complete()),但自 jQuery 1.8 版本起已经被弃用。

.done()

.done()可以用来代替成功回调。只有在文件成功加载后才会调用它。提供的函数将按以下顺序调用以下三个参数:datatextStatusjqXHR

data是加载的文件,这意味着如果你愿意,你可以在那里处理你的 JSON 文件。

.fail()

每当发生问题时都会调用.fail()。提供的函数将按以下顺序调用以下三个参数:jqXHRtextStatusexception

当加载和执行脚本时,如果脚本未被执行,查找发生了什么非常方便。实际上,在大多数浏览器的调试控制台中不会出现异常,但异常参数将包含你的代码抛出的确切异常。

例如,如果我们看一下之前描述的作用域问题,主游戏包含以下代码:

$(function() {
    var someVariable = "someValue";

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

远程脚本如下:

someFunction();

你可以通过编写以下代码来捕获异常:

$.getScript("myScript.js").fail(function(jqxhr, textStatus, exception) {
    console.log("Error: "+exception);
});

控制台将写入以下错误:

error: ReferenceError: someFunction is not defined

这将用于检测其他问题,如服务器无响应等。

修改我们的平台游戏

现在我们已经掌握了创建多级游戏所需的所有知识。首先,我们将创建一个级别列表和一个加载它们的函数:

var levels = [
        {tiles: "level1.json", enemies: "level1.js"},
        {tiles: "level2.json", enemies: "level2.js"}
    ];

    var currentLevel = 0;

    var loadNextLevel = function(group){
        var level = levels[currentLevel++];
        // clear old level
        $("#level0").remove();
        $("#level1").remove();
        for(var i = 0; i < enemies.length; i++){
            enemies[i].div.remove();
        }
        enemies = [];

        // create the new level

        // first the tiles
        gf.importTiled(level.tiles, group, "level");

        // then the enemies
        $.getScript(level.enemies);

        // finaly return the div holdoing the tilemap
        return $("#level1");
    }

高亮显示的行是远程加载文件的行。这使用了之前描述的函数。正如你所看到的,没有机制来检测游戏是否结束。如果你愿意,你可以将其作为作业添加进去!

在加载下一级之前,我们必须确保删除现有的级别以及它包含的敌人。

现在我们将更改游戏以使用逻辑砖块而不是标准砖块。这样我们可以有一种定义一个级别结束的砖块。以下是我们修改后用于执行此操作的碰撞检测代码:

var collisions = gf.tilemapCollide(tilemap, {x: newX, y: newY, width: newW, height: newH});
var i = 0;
while (i < collisions.length > 0) {
    var collision = collisions[i];
    i++;
    var collisionBox = {
        x1: collision.x,
        y1: collision.y,
        x2: collision.x + collision.width,
        y2: collision.y + collision.height
    };

    // react differently to each kind of tile
    switch (collision.type) {
        case 1:
            // collision tiles
            var x = gf.intersect(newX, newX + newW, collisionBox.x1,collisionBox.x2);
            var y = gf.intersect(newY, newY + newH, collisionBox.y1,collisionBox.y2);

            var diffx = (x[0] === newX)? x[0]-x[1] : x[1]-x[0];
            var diffy = (y[0] === newY)? y[0]-y[1] : y[1]-y[0];
            if (Math.abs(diffx) > Math.abs(diffy)){
                // displace along the y axis
                 newY -= diffy;
                 speed = 0;
                 if(status=="jump" && diffy > 0){
                     status="stand";
                     gf.setAnimation(this.div, playerAnim.stand);
                 }
            } else {
                // displace along the x axis
                newX -= diffx;
            }
            break;
        case 2:
            // deadly tiles
            // collision tiles
            var y = gf.intersect(newY, newY + newH, collisionBox.y1,collisionBox.y2);
            var diffy = (y[0] === newY)? y[0]-y[1] : y[1]-y[0];
            if(diffy > 40){
                status = "dead";
            }
            break;
        case 3: 
 // end of level tiles
 status = "finished"; 
 break;
    }

}

如你所见,我们增加了玩家碰到某些砖块时死亡的可能性。这将使他/她重新出现在当前级别的开始处。如果砖块的类型是 3,我们将玩家的状态设置为finished。稍后,我们检测状态并加载下一个级别。

if (status == "finished") {
    tilemap         = loadNextLevel(group);
    gf.x(this.div, 0);
    gf.y(this.div, 0);
    status = "stand";
    gf.setAnimation(this.div, playerAnim.jump);
}

别忘了重置玩家位置,否则它将出现在下一级的中间位置,而不是起始点。

现在我们必须编写每个脚本,为它们各自的级别创建敌人。这几乎是与游戏的先前版本中使用的相同代码,但放在一个单独的文件中:

var group = $("#group");

var fly1   = new Fly();
fly1.init(
    gf.addSprite(group,"fly1",{width: 69, height: 31, x: 280, y: 220}),
    280, 490,
    flyAnim
);
enemies.push(fly1);

var slime1 = new Slime();
slime1.init(
    gf.addSprite(group,"slime1",{width: 43, height: 28, x: 980, y: 392}),
    980, 1140,
    slimeAnim
);
enemies.push(slime1);

var slime2 = new Slime();
slime2.init(
    gf.addSprite(group,"slime2",{width: 43, height: 28, x: 2800, y: 392}),
    2800, 3000,
    slimeAnim
);
enemies.push(slime2);

正如你可能已经想到的那样,我们不能简单地运行游戏并使用该脚本而不对我们的代码进行一些修改。如前所述,远程脚本将在全局范围内执行,我们需要将它使用的部分移到其中。

在这里,我们需要敌人的对象和动画,以及包含敌人列表的数组。我们将它们从它们的闭包中取出,然后添加到我们游戏脚本的开头:

var enemies = [];
var slimeAnim = {
    stand: new gf.animation({
        url: "slime.png"
    }),
    // ...

}
var flyAnim = {
    stand: new gf.animation({
        url: "fly.png"
    }),
    // ...}

var Slime = function() {
    // ...
};
var Fly = function() {}
Fly.prototype = new Slime();
Fly.prototype.dies = function(){
    gf.y(this.div, gf.y(this.div) + 5);
}

$(function() {
   // here come the rest of the game
});

现在游戏将包含我们想要的任意数量的关卡。享受关卡编辑器的乐趣!在这里,我们仅使用脚本来设置敌人,但如果我们想的话,我们也可以用它来更改关卡背景。

摘要

使你的游戏多层级将为你带来一些新的技巧。现在你已经学会了将你的资产分成许多文件,并在需要时加载它们。你还学会了如何使用瓦片来描述逻辑行为,而不仅仅是你的关卡的图形方面。

正如前面提到的,游戏还有很多可以做得更有趣的地方。我建议花一些时间来设计关卡。在大多数商业游戏中,这是花费时间最多的地方,所以不要犹豫,停下编码一段时间,开始制作和测试你的关卡!

在下一章中,你将学习如何制作多人游戏。为此,我们将使用我们在第五章中创建的游戏,将事情放在透视中,并以与我们在本章中使用的第四章中的游戏相同的方式为它添加新功能。

第七章:制作多人游戏

单人游戏是有趣的,正如我们已经看到的,你可以使用 JavaScript 制作各种不同类型的单人游戏。然而,让游戏在网络浏览器中运行,就会有一种很大的诱惑,让它成为多人游戏。这正是我们在本章中要做的,而且什么比一个 MMORPG 更好的多人游戏示例呢!

我们将把我们在第五章中的小型单人 RPG,将事物置于透视之下,变成一个全新的 MMORPG:阿尔皮吉的世界

然而,首先需要警告一下——我们将用来实现游戏服务器端的技术是 PHP + MySQL。这么做的原因是它迄今为止是最常见的技术。如果你有某种类型的托管服务,很可能会直接支持它。

有许多原因说明这不一定是最佳解决方案。当编写一个游戏,其中服务器端的使用不仅仅是为了提供静态页面时,你必须仔细考虑扩展问题:

  • 有多少用户能够同时在你的系统上玩?

  • 当玩家数量超过这个限制时,你将怎么办?

  • 你准备付多少费用来使你的服务器运行?

  • 你想向玩家提供什么样的服务质量?

回答这些问题应该决定你将选择什么技术和基础架构。本书的目的不在于详细讨论这一点;我们将实现的解决方案应该可以扩展到几十名玩家而没有任何问题,但你所学到的技术可以应用于任何你选择的软件解决方案或托管服务!

在本章中,我们将涵盖以下主题:

  • 多人游戏规范

  • 管理玩家账户

  • 同步玩家状态

  • 管理敌人的服务器端

阿尔皮吉的世界

我们将基于我们之前的 RPG 创造的游戏将具有以下特点:

  • 一个玩家可以创建一个账户,并用它登录游戏

  • 当他们回到游戏时,他们的化身将会重新出现在他们离开时的位置

  • 每个玩家都可以看到同时在线的所有其他玩家

  • 其他玩家的名字将显示在他们的头像上方

  • 敌人的状态由服务器端管理:如果有人杀死一个怪物,那么对于所有其他玩家来说,它将会死亡

这个游戏将具有与其基础游戏相同的一些限制。怪物不会反击,也不会四处移动。

管理玩家账户

让我们从基础知识开始:让玩家创建账户并登录游戏。为了在服务器端存储信息,我们将使用一个数据库(MySQL)。我们将使用的表结构非常简单,因为没有太多需要存储的东西。玩家的账户将存储在一个我们会有创意地称为players的表中。

这个表将具有以下行:

  • NAME: 这是一个包含玩家姓名的字符串。它将是唯一的,因此没有两个玩家可以拥有相同的名字。

  • PW:这是一个字符串,保存着玩家的密码。它被哈希化了(关于这点,在数据库中搜索元素 中有更多内容)。

  • X:这是一个双精度浮点数,将保存玩家的 x 坐标。

  • Y:这是一个双精度浮点数,将保存玩家的 y 坐标。

  • DIR:这是一个整数,我们将用来存储玩家面向的方向。

  • STATE:这是一个整数,表示玩家的状态:站立、行走或战斗。

  • LASTUPDATE:这是一个时间戳,记录了服务器最后一次收到玩家消息的时间。

提供了一个 SQL 脚本,该脚本在文件 create_tables.sql 中创建了游戏所需的所有表格。

为了创建允许创建账户或登录游戏的用户界面,我们将使用一系列会重叠游戏屏幕的div。任何时候只有一个会是可见的。以下图展示了可能的用户交互和相应的屏幕:

管理玩家账户

这些屏幕中的每一个都将是一个包含几个输入字段和/或按钮的div。例如,允许玩家创建账户的屏幕将是:

<div id="create" class="screen">
   <h1>Create an account</h1>
   <div class="input"><span>name:</span><input id="create-name" type="text" /></div>
   <div class="input"><span>pw:</span><input id="create-pw" type="text" /></div>
   <a class="button left" id="create-cancel" href="#">cancel</a>
   <a class="button right" id="create-create" href="#">create</a>
</div>

它将用 CSS 进行样式化,交互部分将用 jQuery 编写。对于这个屏幕,代码如下:

$("#create-cancel").click(function(e){
   $("#create").css("display","none");
   $("#login").css("display","block");
   e.preventDefault();
});
$("#create-create").click(function(e){
   // interact with the server
   e.preventDefault();
});

用于将 JavaScript 代码连接到 HTML 代码的链接的 ID 已经突出显示。没什么太复杂的,但是起到了作用。

在前面的代码中故意略去了有趣的部分,即与服务器的实际交互。我们客户端(在浏览器中运行的游戏)与服务器之间的所有交互都将使用 JSON 和我们在上一章中提到的$.getJSON函数进行(这是$.ajax的简写)。

为了将信息传输到服务器,我们将使用$.getJSON函数的第二个参数。为了向客户端传输信息,服务器将生成一个 JSON 文件。

我们将使用服务器端文件来创建一个名为 createUser.php 的账户,因此$.getJSON调用将如下所示:

$.getJSON(
   "createUser.php",
   {
 name: $("#create-name").val(),
 pw: $("#create-pw").val()
 },
   handleCreateUserJson
)

正如我们之前提到的,我们通过将用户选择的名称和密码包装在一个对象文字中,并将其作为第二个参数传递给函数调用来提交它们。正如已经提到的,第三个参数是一个函数,一旦服务器返回它,就会处理 JSON 文件。

在数据库中搜索元素

第一次,我们将不得不生成一个 JSON 文件。这个文件非常简单;它应该告诉客户端账户创建是否成功,如果成功,还有关于玩家的信息。

我们选择将其写成以下代码片段,但是如何创建 JSON 文件是完全由您决定的。如果您对 JSON 文件应该遵循的确切语法不熟悉,请快速阅读 www.json.org/

{
   "success" : true,
   "x" : 510, 
   "y" : 360, 
   "dir" : 0
}

实现函数以读取该 JSON 文件并相应地做出反应非常容易。如果操作成功,我们将启动游戏,并在出现问题时显示错误消息。以下代码就是这样做的:

var handleCreateUserJson = function(json,status){
   if (json.success){
      name = $("#create-name").val();
      initialPlayerPos.x   = json.x;
      initialPlayerPos.y   = json.y
      initialPlayerPos.dir = json.dir;
      $("#create").css("display","none");
      gf.startGame(initialize);
   } else {
      alert("Name already taken!");
   }
}

这相当简单,因为大部分复杂的工作都在服务器上运行。让我们看看在那里需要做什么。首先,我们必须检索客户端发送的参数。由于我们使用了$.getJSON,对 JSON 文件的请求是一个GET请求。这意味着我们将使用 PHP 的$_GET超全局变量来访问它们。当向服务器传递敏感信息时,你可能希望使用POST请求代替(尽管仅靠这一点并不能阻止有动机的人仍然访问参数)。$_GET是一个保存客户端发送的所有参数的变量,因此在我们的情况下,我们可以写成:

$name = $_GET['name'];
$pw    = $_GET['pw'];

我们将把用户选择的名称和密码存储到变量中。现在我们必须查询数据库,检查是否已经定义了一个具有此名称的用户。要在 PHP 中运行 SQL 查询,我们将使用 mysqli (php.net/manual/en/book.mysqli.php):

// 1) contect to the DB server
$link = mysqli_connect('localhost', 'username', 'password'); 

// Select the DB
mysqli_select_db($link, 'rpg');

// query the DB
$result = mysqli_query($link, 'SELECT * FROM players WHERE name = "'.$name.'"');

注意

请注意,上述代码不应用于生产,因为我们直接将用户提供的参数插入到数据库查询中,这会带来巨大的 SQL 注入风险!最佳做法是在将它们注入到 SQL 查询之前始终转义所有字符串。一个简单的方法是使用mysqli_escape (www.php.net/manual/en/mysqli.real-escape-string.php)。

我们不会详细介绍编写 SQL 查询的细节。它们很容易阅读,并且对于像这样的基本查询来说,编写也很容易。如果你想了解更多关于 SQL 的知识,你可以搜索网络或阅读关于该主题的众多书籍之一。

一旦我们得到了查询结果,我们需要检查查询是否返回了一个元素,以查看该名称是否已经存在于数据库中。这只需简单地执行:

$obj = mysqli_fetch_object($result);

现在,如果$obj为零,我们可以创建新账户。

在数据库中创建一个新玩家

在查看将在数据库中创建玩家的查询之前,让我们谈谈密码。你永远不应该在数据库中存储原始密码,因为历史表明数据库经常遭到黑客攻击。推荐的解决方案是在存储密码之前对其进行哈希处理。然后,你可以简单地将提交的密码的哈希版本与数据库中存储的密码进行比较。

在数据库中创建一个新玩家

这就是我们将用 PHP 的hash函数来做的事情。然后我们将简单地将用户名和哈希值与玩家的起始位置一起插入到数据库中。

由于这也是一个查询,我们使用了与之前用来查找账户是否已经存在这个名称相同的函数:

$hash = hash('md5', $pw);
$query = 'INSERT INTO players (name, x, y, dir, pw, state) VALUES("'.$name.'", 510, 360, 0, "'.$hash.'", 0)';
mysqli_query($link, $query);

我们传递给hash函数的第一个参数在前面的代码中被突出显示了。它是哈希方法,我们在这里使用的'md5'现在不推荐用于生产,因为它被认为现在太容易被破解。如果你想了解更多可用的方法,请查看www.php.net/manual/en/function.hash.php中的函数文档。

现在我们可以生成客户端将接收的 JSON 了。这是通过使用 PHP 的json_encode函数完成的(php.net/manual/en/function.json-encode.php)。这个函数接受一个对象并将其转换为 JSON 格式的字符串。

$json['success'] = true;
$json['x'] = 510;
$json['y'] = 360;
$json['dir'] = 0;

echo json_encode($json);

现在,为了让你对客户端文件的整体情况有个全局了解,完整的代码如下所示:

<?php
   session_start();

   include 'dbconnect.php';

    // JSON Object 
    $json = array('success'=>false);

   $name = $_GET['name'];
   $pw    = $_GET['pw'];

   if(isset($name) && isset($pw)) {
      $hash = hash('md5', $pw);
      $query = 'SELECT * FROM players WHERE name = "'.$name.'"';
      $result = mysqli_query($link, $query);
      $obj = mysqli_fetch_object($result);
      if(!$obj){
         $query = 'INSERT INTO players (name, x, y, dir, pw, state) VALUES("'.$name.'", 510, 360, 0, "'.$hash.'", 0)';
         $result = mysqli_query($link, $query);

         $_SESSION['name'] = $name;
         $_SESSION['pw'] = $pw;

            $json['success'] = true;
            $json['x'] = 510;
            $json['y'] = 360;
            $json['dir'] = 0;
      }
   }

    echo json_encode($json);

   // Close DB's connection
   mysqli_close($link);
?>

在这里,你可以看到我们包含了一个名为dbconnect.php的文件,它允许我们在此文件中只写一次数据库配置,并从需要连接到它的每个文件中使用它。这是我们将用于实现服务器端的每个其他功能的同一个基本功能。

保持玩家连接

不过,这个实现中有一件事情我们还没有解释。如果你看一下突出显示的代码,你会看到用户名被存储到了会话中。

这将允许服务器继续知道玩家的姓名,而不必在每次后续请求中都提交它。它还将允许我们允许用户在会话仍然有效的情况下继续玩游戏,而无需再次提供他/她的用户名和密码。

如果你看一下本章开头的用户交互流程图,你会看到有一个屏幕建议用户继续玩游戏。只有在服务器仍然有一个可用于他/她的有效会话时,我们才会显示它。为了检查这一点,我们将创建另一个名为session.php的 PHP 文件,它如下所示:

<?php
   session_start();

   // MySQL connection
   include 'dbconnect.php';

    // JSON Object 
    $json = array('connected'=>'false');

   if(isset($_SESSION['name'])) {
      $query = 'SELECT * FROM players WHERE name = "'.$_SESSION['name'].'"';
      $result = mysqli_query($link, $query);
      $obj = mysqli_fetch_object($result);
      if($obj){
          $json['name'] = $_SESSION['name'];
            $json['x'] = floatval($obj->x);
            $json['y'] = floatval($obj->y);
            $json['dir'] = intval($obj->dir);
      } else {
         session_destroy();   
      }

        mysqli_free_result($result);
   }

    echo json_encode($json);

    mysqli_close($link);
?>

然后我们简单地检查name是否存在于会话中。但是,如果存在,我们还需要做一件事情;那就是从数据库中检索玩家。这将为我们提供其最后一个坐标,并再次检查用户名和密码是否确实匹配。

我们不将坐标保存在会话本身中,因为我们希望玩家能够使用许多不同的机器或浏览器连接到同一个帐户(尽管不能同时进行)。

一旦数据库执行了一个请求,我们就可以使用mysql_result来读取结果。这个函数需要三个参数:

  1. 查询的结果,由mysql_query生成。

  2. 我们想要读取的结果的索引。这是必要的,因为查询可能会返回多个结果(例如,如果我们在players表中搜索所有帐户)。

  3. 我们想要读取的字段的名称。

一旦我们有了这些信息,我们就可以通过将其格式化为 JSON 文件来将其发送给客户端。

在客户端,我们将在游戏开始时调用此函数以选择要显示的屏幕(继续屏幕或登录屏幕)。这与使用$.getJSON调用一样通常。

$.getJSON(
   "session.php",
   function(json){
      if(json.connected){
         name = json.name;
         initialPlayerPos.x   = json.x;
         initialPlayerPos.y   = json.y
         initialPlayerPos.dir = json.dir;
         $("#session-name").html(name);
         $("#session").show(0);
      } else {
         $("#login").show(0);
      }
   }
);

这与我们之前所做的非常相似。

将用户登录游戏

这几乎与我们检查现有会话的方式相同。在服务器端,我们需要发出请求来证明用户名和密码是否匹配,并获取玩家位置。

在客户端,如果密码错误,我们需要显示警告并开始游戏。

我们用于此目的的 JSON 如下所示:

{ 
   "success" : true , 
   "x" : 154,
   "y" : 1043,
   "dir" :0
}; 

如果用户名和密码不匹配,则成功将为 false。否则,JSON 将如之前所示。我们不会向您显示服务器和客户端代码,因为它们与我们已经看到的非常相似。

保持玩家同步

根据我们到目前为止所看到的内容,我们可以登录游戏,但仅此而已;现在我们需要的是一种方法来让服务器了解玩家的移动并给客户端提供所有其他玩家的位置。以下图显示了客户端和服务器将如何交互:

保持玩家同步

我们将在一个 JSON 调用中执行这两个操作。我们将使用它将玩家的当前位置传递给服务器,就像之前为用户名和密码做的那样。作为回报,服务器将生成一个 JSON 文件,其中包含所有其他玩家的列表。

{ 
   "players" : [
      {"name": "Alice", "x": 23, "y": 112, "dir": 0, "state": 0},
      {"name": "Bob", "x": 1004, "y": 50, "dir": 2, "state": 1}
   ]
};

让我们首先看一下服务器端。在那里,我们需要编写两个查询:第一个查询用于检索所有玩家的列表,第二个查询用于更新当前玩家的状态。

检索所有其他玩家

这仅意味着找到players表中除了当前玩家之外的所有条目。但是,有一件事情我们必须小心:我们只想显示当前正在玩游戏的玩家。

由于在线上可能发生很多事情,我们不能确定玩家是否能够在断开连接之前注销,因此,我们选择使用时间戳。每次玩家更新其位置时,我们将时间戳设置为当前时间。

通过比较时间戳和当前时间,我们可以知道哪些玩家不再在线了。我们随意决定,如果我们已经超过 10 分钟没有收到他/她的消息,那么玩家将被视为离线。相应的 MySQL 查询如下:

$query = 'SELECT * FROM players WHERE lastupdate > TIMESTAMPADD(MINUTE, -10, NOW()) AND name <> "'.$_GET['name'].'"';

在这里,我们测试名称是否与当前玩家不同(<>在 SQL 中表示"不等于")。

读取结果并将其打印到服务器响应的代码如下:

$result = mysqli_query($link, $query);

while ($obj = mysqli_fetch_object($result)) {
    array_push($json['players'], array('name'=>$obj->name, 'x'=>floatval($obj->x), 'y'=>floatval($obj->y), 'dir'=>intval($obj->dir), 'state'=>floatval($obj->state)));
}

mysqli_free_result($result);

这与仅从数据库中检索当前用户时非常相似,所以您应该已经熟悉了这段代码。

更新当前玩家位置

要更新数据库中保存有关玩家信息的条目,我们可以使用以下查询:

mysqli_query($link, 'UPDATE players SET x='.$x.', y ='.$y.', dir = '.$dir.', state = '.$state.', lastupdate = NOW() WHERE name="'.$name.'"');

由于我们不期望从此查询中获得任何结果,所以不需要将其存储在任何地方。

客户端代码

现在我们需要编写的代码将当前玩家位置发送到服务器。这并不太复杂,因为只需将参数传递给$.getJSON调用。但是,我们需要将玩家的方向和状态编码为整数(因为我们决定在数据库中以这种方式存储它们)。

为此,我们将扩展玩家对象,添加两个新方法:

this.getState = function(){
    switch (state){
        case "idle":
            return 0;
        case "walk":
            return 1;
        case "strike":
            return 2;
        default:
            return 0;
    }
};

this.getOrientation = function(){
    switch (orientation){
        case "down":
            return 0;
        case "up":
            return 1;
        case "left":
            return 2;
        default:
            return 3; 
    }
}; 

然后,我们将在调用getJSON时简单地调用它们:

$.getJSON(
   "update.php",
   {
      name: name, 
      x: gf.x(player.div), 
      y: gf.y(player.div),
      dir: player.getOrientation(),
      state: player.getState()
   },
   updateOthers
);

回调函数可能是这整章中最复杂的部分。遍历返回的所有玩家列表。如果创建了新玩家,我们需要将他/她添加到地图中。如果玩家移动了,我们需要更新他/她的位置,如果玩家退出了游戏,我们需要将他/她移除。

以下代码确切地做到了这一点:

function(json,status){      
   // Here we need to update the position of all the other players
   var existingOthers = {};
   var players = json.players
   for (var i = 0; i < players.length; i++){
       var other = players[i];
       existingOthers["other_"+other.name] = true;
       var avatar, weapon;
       var div = $("#other_"+other.name);
       var created = false;
       if(div.size() > 0){
          avatar = $("#other_"+other.name+"_avatar");
          weapon = $("#other_"+other.name+"_weapon");
          // update
          gf.x(div, other.x);
          gf.y(div, other.y);
          div.css("z-index",other.y + 160);
       } else {
          var created = true;
          // create other players
          div = gf.addGroup($("#others"), "other_"+other.name, {
             x:      other.x,
             y:      other.y
          })
          others.push( div );
          div.css("z-index",other.y + 160);
          avatar = gf.addSprite(div, "other_"+other.name+"_avatar", {
             x:      (192-128)/2,
                y:      (192-128)/2,
                width:  128,
                height: 128
          });
          weapon = gf.addSprite(div, "other_"+other.name+"_weapon", {
                width:  192,
                height: 192
            });
          div.append("<div style='font-family: \"Press Start 2P\"; background: rgba(0,0,0,0.5); padding: 5px; color: #FFF; width: 192px; position: absolute;'>"+other.name+"</div>");
          div.data("state", {dir: other.dir, state: other.state});
       }

       // set the correct animation
       if(created || other.state !== div.data("state").state || other.dir !== div.data("state").dir){
          div.data("state", {dir: other.dir, state: other.state}); 

          gf.transform(avatar, {flipH: false});
          gf.transform(weapon, {flipH: false});
          var pAnim =  playerAnim.stand;
          var wAnim =  weaponAnim.stand;
          if(other.state === 1){
             pAnim = playerAnim.walk;
            wAnim = weaponAnim.walk;
          } else if (other.state === 2){
             pAnim = playerAnim.strike;
            wAnim = weaponAnim.strike;
          }
          if(other.dir === 0){
             gf.setAnimation(avatar, pAnim.down, true);
             gf.setAnimation(weapon, wAnim.down, true);
          } else if (other.dir === 1){
             gf.setAnimation(avatar, pAnim.up, true);
             gf.setAnimation(weapon, wAnim.up, true);
          } else {
             gf.setAnimation(avatar, pAnim.side, true);
            gf.setAnimation(weapon, wAnim.side, true);
            if(other.dir === 2){
               gf.transform(avatar, {flipH: true});
               gf.transform(weapon, {flipH: true});
            }
          }
       }

   }
   // remove gone others
   for (var i = others.length-1; i >= 0; i--){
      var other = others[i];
      if(!existingOthers[other.attr("id")]){
         other.fadeOut(2000, function(){
                $(this).remove();
            });
            others.splice(i,1);
      }
   }

   setTimeout(updateFunction,100);
}

第一部分是要么更新位置,要么创建其他玩家。第二部分是根据玩家的方向和状态设置正确的动画。

然后我们遍历所有玩家的列表,如果其中一些不在更新玩家列表中,我们就将它们从游戏中移除。

最后,我们设置一个函数调用$.getJSON的超时时间,以便在 100 毫秒后再次调用。你选择的频率将是服务器使用和游戏流畅性之间的折衷,所以你可能需要根据游戏需求微调这个值。

照顾怪物

现在游戏开始变得有趣了。然而,还有一件小事情缺失。如果一个玩家杀死了一个怪物,那么只有对他而言怪物才会死亡,而对其他所有玩家来说并非如此。在一些非常特殊的情况下,这可能没问题,但大多数情况下,这不是我们想要的。

解决方案是在服务器端实现处理敌人和战斗逻辑。这意味着我们需要另一个数据库表来存储所有的敌人。该表需要存储以下信息:

  • 敌人的 ID,用于唯一标识它

  • 敌人的类型——骷髅、食人魔等——用于定义它在玩家眼中的外观

  • 敌人的 x 和 y 坐标

  • 允许玩家杀死它的生命值

  • 它的防御用于战斗系统

  • 它的生成速率,用于确定怪物被击败后再次生成的时间

然后,我们将周期性地向客户端传输这些敌人的位置和属性。由于我们已经有一个定期轮询以获取其他玩家位置的页面,我们可以简单地将其扩展为返回敌人的状态。

这意味着 JSON 文件现在会像这样(其中突出显示了新部分):

{ 
   "players" : [
      {"name": "Alice", "x": 23, "y": 112, "dir": 0, "state": 0},
      {"name": "Bob", "x": 1004, "y": 50, "dir": 2, "state": 1}
   ],
   "enemies" : [
 {"name": "enemy1", "type" : "ogre", "x": 2014, "y": 200},
 {"name": "enemy2", "type" : "skeleton", "x": 220, "y": 560}
 ]
};

我们将需要另一个查询来找出数据库中仍然存活的所有敌人:

SELECT * FROM enemies WHERE life <> 0

编写 JSON 并解析以创建或更新敌人的代码与处理其他玩家的代码完全相同,因此我们不会在此重复,但如果您想要查看完整的源代码,可以看一下。

实施服务器端战斗

要使用那些服务器端的敌人实现战斗,我们仍然可以使用我们在客户端拥有的代码,并将结果发送到服务器。这有一些严重的缺点,因为很容易欺骗系统并修改客户端,简单地发送敌人已被击败的信息而没有真正进行战斗。其次,它使得处理一个敌人和许多玩家之间的战斗变得非常困难。

我们将改为在服务器端实现它,如下图所示:

实施服务器端战斗

以前在客户端执行的代码如下所示:

this.detectInteraction = function(npcs, enemies, console){
    if(state == "strike" && !interacted){
        // ... interaction with NPCs here ...
        for (var i = 0; i < enemies.length; i++){
            if(gf.spriteCollide(this.hitzone, enemies[i].div)){
                var enemyRoll = enemies[i].object.defend();
                var playerRoll = Math.round(Math.random() * 6) + 5;

                if(enemyRoll <= playerRoll){
                    var dead = enemies[i].object.kill(playerRoll);
                    console.html("You hit the enemy "+playerRoll+"pt");
                    if (dead) {
                        console.html("You killed the enemy!");
                        enemies[i].div.fadeOut(2000, function(){
                            $(this).remove();
                        });
                        enemies.splice(i,1);
                    }
                } else {
                    console.html("The enemy countered your attack");
                }
                interacted = true;
                return;
            }
        }
    }

现在我们只需进行一个 JSON 调用:

this.detectInteraction = function(npcs, enemies, console){
    if(state == "strike" && !interacted){
        // ... interaction with NPCs here ...
        for (var i = 0; i < enemies.length; i++){
            if(gf.spriteCollide(this.hitzone, enemies[i])){
                $.getJSON("fight.php",
 { name : enemies[i].attr("id") },
 function(json){
 if (json.hit){
 if (json.success){
 if(json.killed){
 console.html("You killed the enemy!");
 } else {
 console.html("You hit the enemy "+json.damage+"pt");
 }
 } else {
 console.html("The enemy countered your attack");
 }
 }
 })
                interacted = true;
                return;
            }
        }
    }
};

在这里,您可以看到 JSON 包含两个标志,用于提供有关战斗的信息。第一个是hit;如果战斗确实发生了,它就是真的。这是必要的,因为有可能敌人已经死了,而客户端并不知道。然后,success传达了攻击的成功,如果敌人成功防御了自己,则为false,否则为true

战斗的完整逻辑将在fight.php文件中在服务器端实现,但与以前在客户端发生的情况完全相同:

$query = 'SELECT * FROM enemies WHERE life <> 0 AND name = "'.$name.'"';
$result = mysqli_query($link, $query);
$obj = mysqli_fetch_object($result);
if ($obj) {

    $playerRoll = rand ( 5 , 11 );
 $enemyRoll  = rand ( $obj->defense, $obj->defense + 6);

    $json['hit'] = true;

    if ($playerRoll > $enemyRoll){
        $json['success'] = true;

        if($playerRoll > $obj->life){
            $json['killed'] = true;

            // update DB
            mysqli_query($link, 'UPDATE enemies SET life = 0 WHERE name = "'.$name.'"');
        } else {
            $json['killed'] = false;
            $json['damage'] = intval($playerRoll);

            // update DB
            mysqli_query($link, 'UPDATE enemies SET life = '.($obj->life - $playerRoll).' WHERE name = "'.$name.'"');
        }
    }
}

突出显示的部分代表了从客户端拿出并放入服务器的代码。这就是战斗真正需要的一切。

一旦敌人死亡,您可能希望定期重新生成它。最明显的方法是使用服务器端脚本,通过cron命令定期执行。另外,您也可以使用我们创建的任何其他文件来重新生成敌人;例如,每次玩家登录时。

总结

我们在这里创建的游戏迄今为止是本书中写过的最复杂的游戏。当然,通过添加 PvP 战斗,聊天系统等等,它当然可以得到很大的增强,但本章已经涵盖了所有基础知识,使您能够实现这些!

然而,异步调用一堆文件并不是一个非常优雅的解决方案,如果您针对最近的浏览器,您可能希望看一下 WebSocket API,该 API 允许您在浏览器和服务器之间建立和维护双向通信通道。

保持与服务器的永久连接的另一种方法是使用长轮询方法。

在下一章中,我们将修改我们的平台游戏,使其与 Facebook 和 Twitter 集成,并保持高分列表!

第八章:让我们变得社交

自第一款视频游戏诞生以来,一种简单的技术一直被用来保持它们的趣味性——排行榜。排行榜是让玩家继续玩你的游戏的一种简单方法。玩家将尝试每次表现都更好,超过他们的朋友,或者比世界上其他任何玩家表现更好。

社交网络通过允许游戏将玩家的得分发布到他/她的时间线(或动态)为这个简单的想法增加了一个新的维度。这有很多优点,其中一个是它将帮助潜在的新玩家了解你的游戏。如果他们看到他们的一个朋友刚玩了你的游戏,那么他们可能也想试试!

在本章中,我们首先将展示如何使用与前一章中看到的相同技术来实现一个简单的服务器端排行榜。然后,我们将看到如何允许玩家使用他/她的 Twitter 账户登入游戏并代表他/她发推文。

最后,我们将看到如何使用 Facebook 登入游戏,将事件发布到玩家的时间线,并创建成就。

当你使用 Facebook 或 Twitter 时,重要的是要意识到你必须小心遵循他们制定的规则,并且要随时了解规则的变化,以确保你的游戏合规。已经不止一次看到之前被允许使用这些服务的应用程序或游戏随后被禁止的情况。

我们将向您展示如何使用这两个社交网络,但是几乎任何提供相同功能的服务的基本机制都是相同的。

我们会按照以下顺序涵盖这些主题:

  • 创建一个简单的自托管排行榜

  • 使作弊变得更困难

  • 将游戏与 Twitter 集成,以允许玩家发布他/她的得分

  • 将游戏与 Facebook 集成,以允许玩家赢得成就

创建一个简单的排行榜

显然,创建排行榜将需要某种类型的数据库来保存分数。与上一章一样,我们将使用 PHP 和 MySQL 来实现游戏的服务器端。但是,与第七章 制作一个多人游戏不同,一起玩的方法在现实生活中可能是可行的。请求和保存高分是一个几乎不消耗服务器资源并且不经常调用的操作;对于每个用户,我们大约每 10 秒查询一次服务器,与我们在第七章 制作一个多人游戏中每秒多次查询服务器的情况相比,这次不是那么频繁。

首先,我们需要一个作为得分的度量标准。在这里,我们将简单地使用玩家完成一级所需的时间,单位为秒。以下的图表展示了我们将使用的用户互动工作流程:

创建一个简单的排行榜

作为用户界面,我们将使用两个屏幕,我们将以与上一章节界面相同的方式实现它们——简单的div元素,根据需要使它们可见或不可见。

第一个屏幕只是用来宣布级别的开始,并提示用户准备好。第二个屏幕更复杂。它显示玩家的结果、前五名玩家的列表,并且如果玩家得分属于其中之一,给予他/她将姓名保存到此列表的机会。以下截图显示了这将是什么样子:

创建一个简单的排行榜

我们选择使用这种机制而不是在游戏开始时询问用户的姓名,然后自动保存分数,因为这模仿了旧式街机游戏的行为。

这意味着有两个服务器端的动作:

  1. 检索一个级别的前五名得分列表。

  2. 为给定级别保存分数。

我们将用两个文件来实现这两个动作,分别是highscore.phpsave.php

保存高分

我们将使用的数据库表格有三列:

  • Level: 这是一个保存级别索引的整数。

  • Name: 这是一个保存用户名的字符串。

  • Time: 这是一个表示用户完成级别所用秒数的整数。

保存最高分的脚本非常简单——我们将传输姓名、分数和级别到服务器。然后我们将它们用以下 SQL 查询保存到数据库中:

INSERT INTO scores (level, name, time) VALUES (1, "John", 36)

脚本的其余部分与我们在上一章中看到的非常相似,所以我们不会在这里重复,但如果你想要,你可以查看完整的源代码。

检索高分

要检索高分,你只需向服务器提供级别,然后得到分数即可,但我们选择了一个稍微复杂的机制。我们将决定当前用户是否属于前五名列表,并且如果是,则在哪个位置。这将允许你稍后实现防作弊措施。

因此,你将向服务器提供级别和用户的时间,它将返回一个 JSON 文件,其中包含生成排行榜屏幕所需的所有信息。我们选择了以下格式的 JSON:

{ 
  "top" :[
    {"name": "Joe", "time": 14},
    {"name": "John", "time": 15}, 
 {"time": 17},
    {"name": "Anna", "time": 19}
  ],
 "intop": true, 
 "pos": 2
} 

这里的想法是有一个标志来指示玩家是否在前五名列表中,intop。如果这个标志为真,那么另一个名为pos的变量也存在。此变量保存数组top中保存玩家时间的索引。top数组的所有其他条目都是排行榜中玩家的分数,从第一到第五排序。如果intop为假,则数组仅保存其他玩家的分数。

为了生成这个响应,我们首先使用一个 SQL 查询:

SELECT * FROM scores WHERE level=1 ORDER BY time ASC LIMIT 5

这个查询的开始和我们直到现在为止使用的其他查询类似,但在末尾(在上面的前面代码中突出显示)有一个修改器,指定了你希望结果按升序时间排序(ORDER BY time ASC)并且我们只需要五个结果(LIMIT 5)。

解析结果并生成 JSON 不需要做太多工作。唯一需要注意的细节是如果玩家的分数达到了要求,则需要插入玩家的分数。以下是此页面的完整代码:

<?php
  session_start();

  include 'dbconnect.php';

  $time = $_GET['time'];
  $level = $_GET['level'];

  if(isset($time) && isset($level)){

    // JSON Object 
    $json = array('top'=>array(), 'intop'=>false);

    $query = 'SELECT * FROM scores WHERE level='.$level.' ORDER BY time ASC LIMIT 5';
    $result = mysqli_query($link, $query);
    $i=0;

    while ($obj = mysqli_fetch_object($result)) {
 if(!$json['intop'] && $time < $obj->time){
 $json['intop'] = true;
 $json['pos'] = $i;

 array_push($json['top'], array('time'=>$time));

 $i++;
 }
 if($i < 5){
        array_push($json['top'], array('time'=>$obj->time, 'name'=>$obj->name));
        $i++;
      }
    }

 if($i < 5 && !$json['intop']){
 $json['intop'] = true;
 $json['pos'] = $i;

 array_push($json['top'], array('time'=>$time));
 }

    mysqli_free_result($result);

    echo json_encode($json);
  }

  mysqli_close($link);
?>

此代码的突出部分处理了玩家的得分。

显示高分榜

在客户端,我们将生成带有结果的屏幕,并提供一个输入字段,允许玩家将其名称提交到排行榜中,如果他/她愿意的话。让我们看看执行此操作的代码:

var finishedTime = Math.round((Date.now() - levelStart) / 1000);
  $.ajax({
    dataType: "json",
    url: "highscore.php",
    data: {
      level: currentLevel,
      time: finishedTime
    },
    async: false,
    success: function (json) {
      var top = "";
 for (var i = 0; i < json.top.length; i++){
 if(json.intop && json.pos === i){
 top += "<input id='name' placeholder='_____' size='5' />"
 + "<input id='timeScore' type='hidden' value='"+json.top[i].time+"'></input>"
 + "<input id='level' type='hidden' value='"+currentLevel+"'></input>"
 + " "+minSec(json.top[i].time)
 + " <a id='saveScore' href='#'>submit</a> <br>";
 } else {
 top += "" + json.top[i].name + " " + minSec(json.top[i].time) + "<br>";
 }
      }
      $("#top_list").html(top);
    }
  }).fail(function(a,b,c){
    var toto = "toto";
  });

生成列表本身的代码被突出显示了。在这里,我们创建了三个输入字段——一个用于玩家输入他/她的姓名,另外两个隐藏字段用于保存关卡号和玩家分数。它们后面跟着一个链接,用于提交分数。处理此链接的代码如下:

$("#levelEnd").on("click","#saveScore",function(){
    $.get("save.php",{
      name: $("#name").val(),
      time: $("#timeScore").val(),
      level: $("#level").val()
    }, function(){
      $("#saveScore").fadeOut(500);
    });
    return false;
  });

在这里,我们简单地检索输入字段的值,然后将它们提交到服务器。作为对玩家的小反馈,一旦完成,我们就删除提交按钮。

加大作弊难度

避免作弊并没有通用的灵丹妙药。对于使用 JavaScript 编写的游戏来说尤其如此,因为它们的源代码非常容易访问。当然,你可以混淆你的代码,但这只会延缓真正有动力破解你的代码的人。然而,还有一些其他技术可以使在你的游戏中作弊变得更加困难或者效率更低。

服务器端验证

预防作弊最安全的方法是在服务器端进行操作。如果你还记得,在第七章中,我们在我们的 MMORPG 中的战斗机制中确实是这样做的,Making a Multiplayer Game。将相同的范式应用于平台游戏实际上意味着将每次按键都传输到服务器,并让服务器决定玩家的最终位置。

在大多数情况下,这不是一个现实的解决方案。但你仍然可以使用服务器端逻辑来验证玩家提交的分数。你可以在关卡中分布一系列不可见的检查点,在这些检查点上进行服务器的响应。如果用户提交了一个分数,而没有通过每一个检查点,那么肯定是有问题的。你还可以记录一系列指标,比如玩家死亡或跳跃的次数。

问题在于你必须真正为你的游戏定制验证方式;没有通用的方法。然而,非常重要的一点是,你的反作弊措施不应该将一个诚实的玩家标记为作弊者,因为那会引起很多沮丧。你还需要考虑要在这个领域投入多少精力,因为你在这方面花费的时间越多,你在游戏的其他领域花费的时间就越少。

对于您的游戏,我们将实现一些简单的东西。我们知道玩家的移动速度有多快,我们知道级别结束有多远,所以我们可以计算出玩家通过级别所需的最短时间。我们将把玩家的分数与此进行比较,如果不小,则进行验证。

要做到这一点,我们只需在highscore.php中添加这些行:

// player walk may 7px in 30ms -> 233.1
$minTime = array(
 1 => 15, // 3500 / 233.1 
 2 => 15, // 3500 / 233.1 
 3 => 42, // 9800 / 233.1
 4 => 23 // 5460 / 233.1
);
$timeValid = !($minTime[intval($level)] < intval($time));
//...
while ($obj = mysqli_fetch_object($result)) {
  if(!$json['intop'] && $time < $obj->time && $timeValid){
    // ...
  }

如果玩家分数被检测为impossible,它仍将被显示,但玩家不会被提示输入他/她的姓名。

使您的变量不太易读

您可以做的一件事是通过在浏览器的检查器中打开并更改某个值,使作弊游戏变得更加困难,因为我们在发送回服务器之前使用隐藏的输入字段来存储值,以保存最高分。这在纯语义上是有意义的,并使我们的服务器端实现得到了休息,但非常容易被黑客入侵。以下截图显示了用户如果在 Chrome 的页面检查器中打开页面将会看到什么:

使您的变量不太易读

一个简单的经验法则是避免在 DOM 中存储任何重要信息,因为它对任何用户都是可访问的,即使是那些没有太多编程知识的用户也是如此。在我们的情况下,我们将从对save.php的调用中删除这些信息,并改用会话来存储这些值。在highscore.php中,我们可以简单地添加以下代码:

if(!$json['intop'] && $time < $obj->time && $timeValid){
  $json['intop'] = true;
  $json['pos'] = $i;

  array_push($json['top'], array('time'=>$time));

 $_SESSION['level'] = $level;
 $_SESSION['time'] = $time;

  $i++;
}

save.php文件只需在会话中查找级别和时间:

$name = $_GET['name'];
$time = $_SESSION['time'];
$level = $_SESSION['level'];

这个简单的改变已经使得游戏更难以作弊。

对代码进行混淆

对代码进行混淆是一个非常简单的步骤,但会对您有很大帮助。一旦您的代码被混淆,它在检查器中将几乎无法阅读。以下示例是要求排行榜的一段代码:

if (status == "finished") {
  gameState = "menu";
  $("#level_nb_2").html(currentLevel);
  $("#level_nb_1").html(currentLevel + 1);

  var finishedTime = Math.round((Date.now() - levelStart) / 1000);
  $.ajax({
    dataType: "json",
    url: "highscore.php",
    data: {
      level: currentLevel,
      time: finishedTime
    },
    async: false,
    success: function (json) {
      var top = "";
      for (var i = 0; i < json.top.length; i++){
        if(json.intop && json.pos === i){
          top += "<input id='name' placeholder='_____' size='5' />"
            + "<input id='timeScore' type='hidden' value='"+json.top[i].time+"'></input>"
            + "<input id='level' type='hidden' value='"+currentLevel+"'></input>"
            + " "+minSec(json.top[i].time)
            + " <a id='saveScore' href='#'>submit</a> <br>";
        } else {
          top += "" + json.top[i].name + " " + minSec(json.top[i].time) + "<br>";
        }
      }
      $("#top_list").html(top);
    }
  }).fail(function(a,b,c){
    var toto = "toto";
  });

  $("#time").html(minSec(finishedTime));

  $("#levelEnd").fadeIn(2000, function(){
    $("#backgroundFront").css("background-position","0px 0px");
    $("#backgroundBack").css("background-position","0px 0px");
    gf.x(group, 0);

    tilemap = loadNextLevel(group);
    gf.x(player.div, 0);
    gf.y(player.div, 0);
    gf.setAnimation(player.div, playerAnim.jump);
  });
  status = "stand";
}

通过 UglifyJS 进行混淆后的相同代码看起来类似于以下内容:

if("finished"==status){gameState="menu",$("#level_nb_2").html(currentLevel),$("#level_nb_1").html(currentLevel+1);var finishedTime=Math.round((Date.now()-levelStart)/1e3);$.ajax({dataType:"json",url:"highscore.php",data:{level:currentLevel,time:finishedTime},async:!1,success:function(a){for(var b="",c=0;a.top.length>c;c++)b+=a.intop&&a.pos===c?"<input id='name' placeholder='_____' size='5' /><input id='timeScore' type='hidden' value='"+a.top[c].time+"'></input>"+"<input id='level' type='hidden' value='"+currentLevel+"'></input>"+" "+minSec(a.top[c].time)+" <a id='saveScore' href='#'>submit</a> <br>":""+a.top[c].name+" "+minSec(a.top[c].time)+"<br>";$("#top_list").html(b)}}).fail(function(){}),$("#time").html(minSec(finishedTime)),$("#levelEnd").fadeIn(2e3,function(){$("#backgroundFront").css("background-position","0px 0px"),$("#backgroundBack").css("background-position","0px 0px"),gf.x(group,0),tilemap=loadNextLevel(group),gf.x(player.div,0),gf.y(player.div,0),gf.setAnimation(player.div,playerAnim.jump)}),status="stand"}

这已经更难调试了,同时,代码量更小!

使您的网络协议不太易读

一旦客户端代码修复好了,仍然有一个地方作弊者可以访问游戏变量——网络流量。让我们看看当玩家完成级别时,嗅探应用程序可以看到什么:

使您的网络协议不太易读

这是一个问题,因为即使不需要黑客客户端代码,玩家也可以简单地伪造一个带有正确信息的数据包来作弊。以下是您可以做的三件简单事情,使作弊者更难理解您的网络流量:

  1. 为变量赋予随机名称,以便作弊者仅凭看它们就无法找出它们保存的值。

  2. 对变量的内容进行编码。这对于此情况非常有用,因为在这里用户通常知道自己分数的值。他/她只需查找保存它的变量,就可以找出需要修改的内容。

  3. 添加大量随机变量,以使很难知道哪些真正被使用了。

像以前一样,这只会让决心的玩家稍微难以作弊,但与以下各节中的所有其他技术结合起来,它可能会阻止大多数人。让我们实施这些技术。

编码数值

让我们首先开始编码数值。这可以用许多方式来完成,有些比其他更安全。在这里,我们的目标只是防止作弊者从值列表中搜索他/她的分数以确定哪个持有它。所以,我们不需要任何复杂的编码。我们将简单地使用左移(客户端上的<<)然后右移(服务器上的>>)。

这里是客户端代码:

$.ajax({
  dataType: "json",
  url: "highscore.php",
  data: {
    level: currentLevel,
 time: finishedTime << 1
  },
  async: false,
  success: function (json) {
    // ...
  }
});

服务器端对应如下:

$time = intval($_GET['time']) >> 1;

为了进一步迷惑用户,我们将以清晰的方式传输数值到许多其他变量中,这些变量在服务器端是无法读取的。

随机命名变量

这里没有太多需要解释的内容;只需替换变量的名称!如果你真的很偏执,那么每次调用服务器时都可以更改变量,但我们不会这样做。以下是客户端代码:

$.ajax({
  dataType: "json",
  url: "highscore.php",
  data: {
 Nmyzsf: currentLevel,
 WfBCLQ: finishedTime << 1
  },
  async: false,
  success: function (json) {
    // ...
  }
});

服务器端代码如下:

$time = intval($_GET['WfBCLQ']) >> 1;
$level = $_GET['Nmyzsf'];

添加随机变量

现在变量的名称不再传达它们的内容,非常重要的是你创建更多变量,否则很容易只是尝试每一个来找出哪一个包含分数。以下是您在客户端可能做的示例:

$.ajax({
  dataType: "json",
  url: "highscore.php",
  data: {
 sXZZUj: Math.round(200*Math.random()),
 enHf8F: Math.round(200*Math.random()),
 eZnqBG: currentLevel,
 avFanB: Math.round(200*Math.random()),
 zkpCfb: currentLevel,
 PCXFTR: Math.round(200*Math.random()),
    Nmyzsf: currentLevel,
 FYGswh: Math.round(200*Math.random()),
 C3kaTz: finishedTime << 1,
 gU7buf: finishedTime,
 ykN65g: Math.round(200*Math.random()),
 Q5jUZm: Math.round(200*Math.random()),
 bb5d7V: Math.round(200*Math.random()),
 WTsrdm: finishedTime << 1,
 bCW5Dg: currentLevel,
 AFM8MN: Math.round(200*Math.random()),
 FUHt6K: Math.round(200*Math.random()),
    WfBCLQ: finishedTime << 1,
 d8mzVn: Math.round(200*Math.random()),
 bHxNpb: Math.round(200*Math.random()),
 MWcmCz: finishedTime,
 ZAat42: Math.round(200*Math.random())
  },
  async: false,
  success: function (json) {
    // ...
  }
});

服务器不需要做任何更改,因为这些新变量只是被忽略的。你可能想做一些事情,比如重复值,并在不会被使用的变量上使用玩家分数。

在做这些事情的同时,您必须非常小心地注释代码,以便记住哪些变量是正确的!

与 Twitter 集成

Twitter 是与其他人分享简单信息的绝佳方式。您可能希望以两种方式使用它:

  • 允许玩家登录,从而提供一个唯一的用户名

  • 允许玩家发布他/她在游戏中的最高分或进度

现在你将看到两种将你的游戏与之集成的可能性。

Twitter 入门指南

有一种非常简单的方法可以使用 Twitter,甚至不需要您使用任何类型的 API。如果用户已经登录到 Twitter,您可以提示他/她通过打开一个 URL 提交一个预先写好的推文。这个 URL 的格式如下:

http://twitter.com/home?status=Pre written status here!

此地址的突出部分是您为玩家编写的状态。我们在游戏中可以做的是在排行榜屏幕上的提交按钮旁提供一个tweet this链接:

$.ajax({
  dataType: "json",
  url: "highscore.php",
  data: {
    // ...
  },
  async: false,
  success: function (json) {
    var top = "";
    for (var i = 0; i < json.top.length; i++){
      if(json.intop && json.pos === i){
        top += "<input id='name' placeholder='_____' size='5' />"
          + " "+minSec(json.top[i].time)
          + " <a id='saveScore' href='#'>submit</a>"
          + " <a id='tweetScore' target='_blank' href='http://twitter.com/home?status="+escape("I've just finished level "+currentLevel+" in YAP in "+minSec(json.top[i].time)+"!")+"'>tweet</a> <br>";
      } else {
        top += "" + json.top[i].name + " " + minSec(json.top[i].time) + "<br>";
      }
    }
    $("#top_list").html(top);
  }
});

突出显示的部分就是魔法发生的地方。您会注意到我们使用了 JavaScript 的escape函数来确保我们提供的字符串格式化为 URL。

这种方法非常容易实现,但有一些限制:

  • 如果用户尚未登录,则必须先登录后才能发布推文。

  • 您无法访问用户的 Twitter 账号来用于本地排行榜。这意味着如果玩家想要发送推文并节省时间,那么名字也必须在这里输入。

  • 对于每条推文,都会打开一个新窗口,玩家必须确认。

如果您想要允许用户登录并自动发布推文,而无需每次都打开新窗口,则必须使用 Twitter 的 API。

获得完整的 Twitter API 访问权限

与 Twitter 集成的更完整的解决方案是要求用户允许将其账户连接到游戏。此基本机制使用 OAuth,这是一种得到很多公司支持的开放认证标准,如 Twitter、Google 和 Facebook。

要让玩家选择是否使用 Twitter 登录,我们将稍微更改启动屏幕:

获得完整的 Twitter API 访问权限

如果玩家点击 开始游戏,那么他/她将开始游戏。如果他/她点击 用 Twitter 登录,那么他/她将被提示授权游戏与 Twitter,并然后返回游戏的启动屏幕。

在 Twitter 注册您的游戏

在做任何其他事情之前,您必须先在 Twitter 上注册您的游戏。要做到这一点,首先您需要登录 Twitter 开发者网站 (dev.twitter.com)。然后,您可以点击 我的应用程序

在 Twitter 注册您的游戏

在这里,您可以点击 创建新应用,填写所有必填字段,并同意 规则 条款和条件。一旦完成,您将收到一个屏幕提示,向您展示您新创建的应用程序的所有属性:

在 Twitter 注册您的游戏

请注意此屏幕截图中的两个圈起来的代码区域;您稍后会需要它们。在这里还有一件您需要配置的事情。转到 设置 选项卡,滚动到 应用程序类型。这里,默认选择 只读。如果您想要能够代表用户发布推文,则需要将其更改为 读写

在 Twitter 注册您的游戏

就这样;你的游戏现在应该在 Twitter 方面正确配置了。

服务器端辅助库

您可以直接在 PHP 中实现与 Twitter API 的所有交互,但这将是繁琐的;幸运的是,存在许多库可以帮助您。PHP 的一个叫做 twitteroauthgithub.com/abraham/twitteroauth)。其他语言有其他库,所以不要犹豫,查看 Twitter 的开发者文档以了解更多信息。

twitteroauth 的非常好的一点是,你几乎可以将其安装在支持 PHP 的几乎任何类型的托管上。你只需要将库文件复制到与游戏文件相同的目录中即可。在我们的例子中,我们将它们复制到一个名为twitter的子目录中。

现在,您需要配置该库。为此,请从twitteroauth文件夹中打开config.php

define('CONSUMER_KEY', '(1)');
define('CONSUMER_SECRET', '(2)');
define('OAUTH_CALLBACK', '(3)');

在这个文件中,在(1)(2)处,你必须写下你之前在 Twitter 开发者网站上的应用页面中记下的两个值。然后,在(3)处,你必须写下 twitteroauth 的callback.php文件的 URL。

最后一步是编辑callback.php,并用你游戏的索引文件的地址替换以下行:

header('Location: ./index.php');

身份验证

这是用于使用 Twitter 对您的游戏进行身份验证和授权的工作流程:

身份验证

这并不像看起来的那么复杂,而这个工作流程的一大部分已经由 twitteroauth 实现了。我们现在将创建一个带有Twitter按钮的登录页面。我们将使用一个简单的链接,指向 twitteroauth 的redirect.php文件。当玩家第一次点击它时,他/她将被重定向到 Twitter 网站上的一个页面,要求他/她授权该游戏:

身份验证

然后,一旦玩家这样做,他/她将被重定向回您在callback.php文件中指定的 URL。如果玩家已经这样做过一次,他/她将能够直接登录。

从现在开始有用的是,在我们的 JavaScript 代码中知道玩家是否已经连接或没有。为此,让我们将我们的游戏 HTML 文件转换为 PHP 文件,并在其开头添加以下代码:

<?php 
session_start();

require_once('twitter/twitteroauth/twitteroauth.php');
require_once('twitter/config.php');

/* Get user access tokens out of the session. */
$access_token = $_SESSION['access_token'];
$connection = new TwitterOAuth(CONSUMER_KEY, CONSUMER_SECRET, $access_token['oauth_token'], $access_token['oauth_token_secret']);
$user = $connection->get('account/verify_credentials');

?>

此代码启用了会话跟踪,包括twitteroauth库的一些文件,然后检查会话中是否存储了访问令牌。如果玩家使用 Twitter 登录,则会出现这种情况。

然后,服务器连接到 Twitter 以检索用户对象。这一切都很好,但 JavaScript 代码仍然对所有这些一无所知。我们需要的是创建一个自定义脚本,其中包含我们想要传输给客户端 JavaScript 的值:

<script type="text/javascript">
<?php if($_SESSION['status'] == 'verified'){ ?>
  var twitter = true;
  var twitterName = "<?php print $user->screen_name; ?>";
<?php } else { ?>
  var twitter = false;  
<?php } ?>
</script>

现在,如果玩家使用 Twitter 登录,我们将全局变量twitter设置为true,并且全局变量twitterName保存玩家的屏幕名称。

你可能想做的最后一件事是向用户提供他/她已成功使用 Twitter 登录的反馈,并为他/她提供注销的可能性。为此,如果玩家已经登录,则我们将轻微更改开始屏幕:

<div id="startScreen" class="screen">
 <?php if($_SESSION['status'] != 'verified'){ ?>
 <a class="button tweetLink" href="./twitter/redirect.php">Login with Twitter</a> 
 <?php } else { ?>
 <a class="button tweetLink" href="./twitter/clearsessions.php">Logout from Twitter</a>
 <?php }?>
  <a id="startButton"class="button" href="#">Start game</a>
</div>

通过这些相对较小的更改,您已经通过 Twitter 实现了身份验证。

在 Twitter 上发布高分

现在用户已连接到 Twitter,你可以让他/她以更无缝的方式发布他/她的时间。为此,我们将创建一个名为 twitterPost.php 的新的服务器端脚本。这个文件将使用 Twitter 的 statuses/update API。

让我们看看完整的脚本:

<?php
session_start();
require_once('twitter/twitteroauth/twitteroauth.php');
require_once('twitter/config.php');

$time = $_SESSION['time'];
$level = $_SESSION['level'];
if(isset($time) && isset($level)){
  /* Get user access tokens out of the session. */
  $access_token = $_SESSION['access_token'];
  $connection = new TwitterOAuth(CONSUMER_KEY, CONSUMER_SECRET, $access_token['oauth_token'], $access_token['oauth_token_secret']);

 $parameters = array('status' => 'I\'ve just finished level '.$level.' for Yet Another Platformer in '.$time.' seconds!');
 $status = $connection->post('statuses/update', $parameters); 
}
?> 

你可能会认出我们在游戏页面开头添加的大部分代码(只有高亮部分是新的)。最后两行代码创建并发送到 Twitter 你想要发布的状态。这很简单直接,但我们可以做的更多——因为玩家已登录,你知道他/她的用户名,你可以用来制作排行榜。

在客户端代码中,我们将生成一个稍微不同版本的排行榜,如下所示:

$.ajax({
  dataType: "json",
  url: "highscore.php",
  data: {
    // ...
  },
  async: false,
  success: function (json) {
    var top = "";
    for (var i = 0; i < json.top.length; i++){
      if(json.intop && json.pos === i){
 if (twitter){
 top += "<input id='name' type='hidden' val='"+twitterName+"'/>"
 + twitterName + " " + minSec(json.top[i].time)
 + " <a id='saveScore' href='#'>submit</a>"
 + " <a id='tweetScore' href='#'>tweet</a> <br>";
 } else {
          top += "<input id='name' placeholder='_____' size='5' />"
          + " "+minSec(json.top[i].time)
          + " <a id='saveScore' href='#'>submit</a>"
          + " <a target='_blank' href='http://twitter.com/home?status="+escape("I've just finished level "+currentLevel+" in YAP in "+minSec(json.top[i].time)+"!")+"'>tweet</a> <br>";
        }
      } else {
        top += "" + json.top[i].name + " " + minSec(json.top[i].time) + "<br>";
      }
    }
    $("#top_list").html(top);
  }
});

在这里,我们将包含玩家名称的输入字段隐藏起来,并填入用户的用户名。然后,在排行榜中写入用户名。这个好处是,服务器端代码完全不需要改变。

这就是我们在 Twitter 中实现的所有内容了,但我鼓励你去看一看完整的 Twitter API,并且发挥创造力!

与 Facebook 集成

在很多方面,与 Facebook 的集成类似于与 Twitter 的集成。然而,Facebook 提供了更多的游戏定向。在我们的情况下,我们将为已登录用户实施成就。我们将使用 Facebook 的 PHP SDK,但也支持其他语言。

至于 Twitter,我们需要首先在 Facebook 中注册我们的应用程序。要做到这一点,登录到 Facebook 的开发者网站(developers.facebook.com/)并点击页眉中的 Apps

与 Facebook 集成

然后,点击 Create New Apps 并填写所需的信息。然后你将看到新创建的应用程序页面。在这里,你需要记下下面截图中显示的两个值(就像我们为 Twitter 所做的那样):

与 Facebook 集成

如果你看一下上述截图中的红色箭头,你会注意到你可以选择你的应用和 Facebook 将如何交互。要完全访问 Facebook 的 Open Graph API,其中包括发布成就在内,你需要选择 App on Facebook

这将允许你的游戏加载到 Facebook 的 iframe 中。不过,你需要在你的域名上安装有效的 HTTPS 证书。但是,如果你只希望你的游戏从你自己的服务器加载,那么你就不需要任何(你仍然需要在相应字段中输入一个地址,并且你可以简单地在你的不安全地址前加上 https 来使其有效)。

有一个最后需要做的步骤,即使你的 Facebook 应用程序能够提供成就——将它注册为游戏。要做到这点,只需在左侧点击 App Details。然后,在 App Info | Category 下选择 Games,如下面的截图所示:

与 Facebook 集成

与 Facebook 进行身份验证

Facebook 的基本身份验证机制与 Twitter 的非常相似。然而,关于访问的一个小差别在于,在 Twitter 中,您必须定义您的应用程序在开发者网站上需要读取和写入访问权限,而在 Facebook 中,您要求用户的访问权限的细粒度要高得多,只有在登录阶段才能指定这些。

让我们来看看身份验证所需的代码。就像对于 Twitter 一样,我们将首先编写在游戏文件的开头尝试获取用户的指令:

<?php 
session_start();

// Twitter ... 

// Facebook
require 'facebook/facebook.php';

$app_id = '(1)';
$app_secret = '(2)';
$app_namespace = 'yap_bookdemo';
$app_url = 'http://yetanotherplatformer.com/';
$scope = 'publish_actions';

$facebook = new Facebook(array(
  'appId' => $app_id,
  'secret' => $app_secret,
));

// Get the current user
$facebookUser = $facebook->getUser();

?>

突出显示的行定义了我们希望我们的游戏能够在玩家的时间轴上发布条目。值(1)(2)是你在应用程序配置页面中记录的值。

如果$facebookUser为空,这意味着用户已经登录,否则我们将不得不显示一个登录按钮。为此,我们将编写一个与我们为 Twitter 编写的代码非常相似的代码:

<div id="startScreen" class="screen">
  ...
 <?php if(!$facebookUser){ 
 $loginUrl = $facebook->getLoginUrl(array(
 'scope' => $scope,
 'redirect_uri' => $app_url
 ));
 ?>
 <a class="button tweetLink" href="<?php print $loginUrl; ?>">Login with Facebook</a>
 <?php } else { 
 $logoutUrl = $facebook->getLogoutUrl(array(
 'next' => $app_url
 )); 
  ?>
    <a class="button tweetLink" href="<?php print $logoutUrl; ?>">Logout from Facebook</a>
  <?php } ?>
  <a id="startButton"class="button" href="#">Start game</a>
</div>

在这里,您可以看到 Facebook 的 PHP SDK 提供了一个方便的方法来生成用户登录或注销的 URL。

现在,我们将添加一小段代码来指示 JavaScript 代码用户是否已经登录到 Facebook。再一次,这里的代码与我们用于 Twitter 的代码非常相似:

<script type="text/javascript">
   // ...
  <?php if($facebookUser){ ?>
    var facebook = true;
    var facebookId = "<?php print $facebookUser; ?>";
  <?php } else { ?>
    var facebook = false;  
  <?php } ?>
</script>

创建成就

现在我们将为我们的游戏创建一个成就。为此,您需要在服务器上有两个文件:

  • 一个具有一系列meta标签的 HTML 文件

  • 一幅图像文件,将在玩家的时间轴上代表成就

HTML 文件不仅作为成就的配置文件,还将链接到在您玩家的时间轴上发布的成就。为了使 Facebook 认可成就有效,您需要在头部定义以下七个meta标签:

  • og:type包含值game.achievement。它区分了成就与其他类型的 OpenGraph 实体。

  • og:title是成就的非常简短的描述。

  • og:url是当前文件的网址。

  • og:description是成就的较长描述。

  • og:image是前面提到的图像。它可以是 PNG、JPEG 或 GIF 格式,并且至少有 50 x 50 像素的大小。最大的长宽比是 3:1。

  • game:points是与此成就相关联的积分数。总共,您的游戏不能给出超过 1000 点,最小允许的数字是 1。具有更高点值的成就将更有可能显示在玩家的好友的新闻动态中。

  • fb:app_id是您的应用程序的 ID。

HTML 文件的正文可以是一个很好的页面,解释这个成就到底是什么,或者任何你真正想要的东西。一个完整的成就页面的非常简单的例子如下:

<html> 
  <head>
    <meta property="og:type" content="game.achievement" />
    <meta property="og:title" content="Finished level 1" />
    <meta property="og:url" content="http://8bitentropy.com/yap/ach1.html" />
    <meta property="og:description" content="You just finished the first level!" />
    <meta property="og:image" content="http://8bitentropy.com/yap/ach1.png" />
    <meta property="game:points" content="50" />
    <meta property="fb:app_id" content="(1)" />
  </head>
  <body>
    <h1>Well done, you finished level 1!</h1>
  </body>
</html>

生成的成就将会在玩家的时间轴上显示如下截图:

创建成就

但仅仅写这份文档还不足以完全配置您的成就。您需要将其提交给 Facebook。为了做到这一点,您必须在正确的 URL 上使用正确的参数进行POST请求。这个请求还应该关联一个应用程序令牌。

应用程序令牌是 Facebook 确保通信对象真的是您的游戏而不是其他应用程序的一种方式。最简单的方法是编写一个 PHP 页面来提交您的成就。下面是完整代码:

<?php

require 'facebook/facebook.php';

$app_id = '(1)';
$app_secret = '(2)';
$app_namespace = 'yap_bookdemo';
$app_url = 'http://yetanotherplatformer.com/';
$scope = 'publish_actions';

$facebook = new Facebook(array(
  'appId' => $app_id,
  'secret' => $app_secret,
));

$app_access_token = get_app_access_token($app_id, $app_secret);
$facebook->setAccessToken($app_access_token);

$response = $facebook->api('/(1)/achievements', 'post', array(
 'achievement' => 'http://yetanotherplatformer.com//ach1.html',
));

print($response);

// Helper function to get an APP ACCESS TOKEN
function get_app_access_token($app_id, $app_secret) {
  $token_url = 'https://graph.facebook.com/oauth/access_token?'
   . 'client_id=' . $app_id
   . '&client_secret=' . $app_secret
   . '&grant_type=client_credentials';

  $token_response =file_get_contents($token_url);
  $params = null;
  parse_str($token_response, $params);
  return $params['access_token'];
}

?>

这段代码非常冗长,但您将会认出其中大部分内容。重要部分已经标出——首先,我们检索应用程序令牌,然后将其与将来的请求关联,最后使用 SDK 进行POST请求。

这个POST请求的地址格式如下:"应用程序 ID" / "achievements"。传输的参数就是成就文件的 URL。

由于此处生成的错误消息(如果出现问题)可能相当难以理解,您可能首先希望使用 Facebook 提供的调试工具对成就文件进行验证,网址为developers.facebook.com/tools/debug/

发布成就

现在 Facebook 已经注册了成就,我们可以将其授予我们的玩家。执行这个命令也是一个POST请求,必须关联一个应用程序令牌。为了简单起见,我们将创建一个简单的 PHP 页面,在被调用时授予成就。在现实情况下,这绝不是最佳方案,在那种情况下,您希望避免让用户自行调用这个文件。您可以在highscore.php文件中授予成就。

这是该文件的完整代码;它与我们用来注册成就的文件非常相似,不同之处已经标出:

<?php 
session_start();

// Facebook
require 'facebook/facebook.php';

$app_id = '(1)';
$app_secret = '(2)';
$app_namespace = 'yap_bookdemo';
$app_url = 'http://yetanotherplatformer.com/';
$scope = 'publish_actions';

$facebook = new Facebook(array(
  'appId' => $app_id,
  'secret' => $app_secret,
));

// Get the current user
$facebookUser = $facebook->getUser();

$app_access_token = get_app_access_token($app_id, $app_secret);
$facebook->setAccessToken($app_access_token);

$response = $facebook->api('/'.$facebookUser.'/achievements', 'post', array(
 'achievement' => 'http://yetanotherplatformer.com/ach1.html'
));

print($response);

// Helper function to get an APP ACCESS TOKEN
function get_app_access_token($app_id, $app_secret) {
  ...
}

?>

这次,我们创建一个POST请求到一个 URL,格式为:"用户 ID" / "achievements"。现在,我们只需在用户完成第一关时从游戏中异步调用此文件:

if (status == "finished") {
  ...
 if(facebook && currentLevel === 1){
 $.get("ac h1.php");
 }
  ...

概要

在这一章中,我们学到了很多,尽管我们只是探索了新工具所可能具有的社交互动的表面。Facebook 和 Twitter 的 API 非常庞大且不断变化。如果您希望以最佳方式使用它们,我真的建议阅读它们的完整文档。

但是,当使用第三方服务时,尤其是免费的那些,您必须意识到您变得依赖它们了。它们可以随时更改任何内容,而不会通知您太多。它们可以决定不再让您的游戏使用它们的服务。始终记住这一点,如果可能的话,确保您在这些情况下有一个退出策略!

在下一章中,我们将探讨另一个热门话题——使你的游戏适用于移动设备!为此,我们将把我们的平台游戏扩展到可以在现代智能手机和平板电脑上运行。

第九章:制作您的游戏移动端

移动设备正在迅速成为游戏的首选平台。好消息是,大多数这些设备中的 Web 浏览器都相当不错,在大多数情况下,您可以使您的移动游戏在其上顺利运行。

但是,这些设备具有一些内存和电源限制。目前有一些游戏在移动浏览器上根本无法运行。您不能指望在智能手机上运行和桌面计算机性能只有十分之一的设备上顺畅运行同样数量的精灵。

移动设备的一个优点是,它提供了一些通常在桌面上找不到的功能:

  • 多点触摸界面允许您以新的方式与您的游戏互动

  • 设备方向 API 允许您以有趣的方式控制您的游戏或 UI。

  • 大多数设备允许您的游戏像原生应用一样安装到“springboard”,模糊了浏览器游戏和原生游戏之间的界线。

  • 离线缓存允许您的游戏即使在设备上没有活动的互联网连接时也能工作。

在本章中,我们将采取我们的 MMORP 并使其在 iOS 设备上运行。我们将使用的大多数 API 都是事实上的标准,并且也支持 Android。以下是我们将要涵盖的主题的简要概述:

  • 处理移动设备的性能限制

  • 为我们的游戏添加多点触控控制

  • 将我们的游戏与 springboard 和其他移动特定配置集成

  • 使用设备方向 API

  • 利用 Web 存储和离线应用缓存

我们选择只考虑 iOS 方面的原因有几个:

  • 尽管安卓最近赶上了,但 iOS 仍然是全球使用最广泛的移动操作系统(根据来源和什么被认为是移动设备,您会发现 iOS 的市场份额在 30% 到 50% 之间)。

  • 即使苹果选择禁止第三方浏览器进入其操作系统在某种程度上引起了争议,但它具有积极的副作用,即使 Web 开发变得更加容易。实际上,您不必在浏览器端处理太多的差异。

  • 移动浏览器上可用的大多数特定 API 首先是由苹果在 Webkit 移动端上创建或实现的。

在我们开始之前,我想强调这一点,这是一个比 Web 开发世界其他领域发展得更快的领域。新的 API 定期添加,每个新设备的性能明显优于其替代品。如果您认真考虑制作充分利用移动设备的游戏,您应该投入一些时间来使自己了解这些变化。

使您的游戏在移动设备上运行良好

性能问题可能是开发基于浏览器的移动游戏时会遇到的最大问题,主要原因是有各种各样的设备可用,每个设备的功能都非常不同。

即使你选择仅支持 iOS,这可能是目前最简单的生态系统,你仍然会在性能、屏幕分辨率和浏览器支持方面有很大的差异。

为了了解情况的复杂性,可以查看 jQuery Mobile 支持的设备(jquerymobile.com/gbs/)。对于你的游戏,你可能应该有一个类似于他们的方法;选择几个设备/软件版本作为目标。你的游戏应该在这些设备上无缝运行。

然后确保游戏在更广泛的设备上无错误运行。在这些设备上,性能可能不理想。最后,明确划定一个线,超过这条线你甚至都不会去测试游戏是否能够运行。

每个类别的大小将取决于你想要投入多少精力。一个问题是你实际上不能使用每个平台 SDK 提供的模拟器来调查性能问题。这意味着最终你将不得不在实际设备上测试你的游戏。

这对于大公司来说不是问题,但如果你是一个小型独立游戏开发者,你可能会发现这是一个限制你支持的设备数量的因素。

检测移动浏览器

为了应对桌面和移动设备之间的差异,有许多可能的方法:

  1. 只设计一个游戏,专注于移动设备。它也可以在桌面上毫无问题地运行,但可能不像专门为桌面设计的那样美观或复杂。好处是,如果玩家在你的游戏中相互竞争,他们都将处于同一水平。

  2. 设计两个游戏,一个优化用于桌面,一个用于移动。这几乎是两倍的工作量,但你可能会共享大部分艺术、音乐和服务器端代码(如果有)。从性能上讲,这是理想的解决方案,但如果你的游戏中有 PvP(玩家对玩家),那么在一个平台上的玩家与其他平台上的玩家相比可能更具优势。

  3. 如果游戏在桌面浏览器上运行,你可以只设计一个游戏,但是增加一些纯粹的装饰性功能。通过这种解决方案,你只需要一个代码库,但可能会稍微复杂一些。PvP 游戏的问题仍然存在。

你将选择遵循的方法将取决于你的优先级,但对于第二和第三种方法,你需要检测玩家运行游戏的平台类型。

根据你想要多精确,这可能是一个相当复杂的任务。基本上有两种一般的方法可以使用:客户端检测和服务器端检测。

客户端浏览器检测

如果你想要实现之前描述的第三种方法,即在客户端检测浏览器,那么这是非常合理的。最常见的方法是使用navigator.userAgent字符串(UA简称)。这个变量包含一个非常长和晦涩的字符串,其中包含了大量信息。

需要记住的是浏览器可以伪造这个字符串(这被称为UA 伪装)。例如,在 Safari 中,你可以指定它模仿哪个浏览器。好处是移动设备通常不会在用户部分进行某些黑客行为。此外,一些非常不同的移动设备具有相同的 UA,例如桌面和移动版本的 Internet Explorer。

其中很大一部分是出于遗留原因,你真的不应该关心它,但通过查看这个更长字符串中特定字符串的出现,你可以检测到你正在处理的浏览器的类型。例如,如果userAgent字符串包含iPhone,你就知道浏览器是在 iPhone 上运行的 Safari 移动版。相应的 JavaScript 代码可能如下所示:

if(navigator.userAgent.match(/iPhone/i)){
    // iPhone detected
    // ...
} else {
   // not an iPhone
}

现在这对于 iPhone 可能有效,但如果你的用户使用的是 iPad,则不会被检测到。你必须查找字符串iPad来检测 iPad。对于 iPod Touch 也是一样,你必须查找iPod。如果你想区分 iDevices 和其他设备,你可以这样做:

if(navigator.userAgent.match(/iPhone|iPod|iPad/i){
    // iDevice detected
    // ...
} else {
   // not an iDevice
}

如果你希望精确检测各个设备,你应该使用以下代码:

if(navigator.userAgent.match(/iPhone/i)){
  // iPhone detected
} else if(navigator.userAgent.match(/iPad/i)) {
 // iPad detected
} else if(navigator.userAgent.match(/iPod/i)) {
 // iPod touch detected
} else {
   // not an iDevice
}

正如你所想象的,如果你想要检测大量设备,这个列表可能很快变得相当长。希望存在着确切完成你目标的代码片段。如果你只想检测移动设备,你可以使用 detectmobilebrowsers.com/ 提供的脚本。如果你想更精确地控制你要检测的内容,你可以使用由总是出色的 Peter-Paul Koch 提供的脚本,网址为 www.quirksmode.org/js/detect.html

服务器端检测

如果你想要实现第二种方法(为移动和桌面浏览器提供不同版本的游戏),你可能会想要在服务器上检测玩家的浏览器,并将他们重定向到游戏的正确版本。与客户端检测一样,最常见的技术使用浏览器的userAgent字符串。

如果你使用 PHP,你会很高兴地了解到它几乎支持开箱即用的浏览器检测。实际上,你可以使用 get_browser 函数与一个最新的 php_browscap.ini 文件结合使用,以获取有关浏览器的信息(你可以在 tempdownloads.browserscap.com/ 找到各种版本的此文件)。你将不得不在你的 php.ini 文件中配置 browscap 属性,将其指向你的 php_browscap.ini 文件,以便它被识别。复制我们先前实现的客户端检测的代码将如下所示:

$browser = get_browser(null);

if($browser->platform == "iOS"){
  echo "iOS";
} else {
  echo "not iOS";
}

这与客户端实现具有相同的缺点:浏览器可以伪造 userAgent 字符串。

你真的需要检测浏览器吗?

通常不建议检测浏览器。首选解决方案通常是使用功能检测。例如,如果你想使用设备方向,那么你只需在运行时检查相应的 API 是否可用,这样做真的很有意义。

在这种情况下,这是一种更为健壮的方法,但我们讨论的是对游戏性能的优化。没有可以检测的特性会提供有关这方面的信息。在这种情况下,我认为检测浏览器是有意义的。

更健壮的替代方案是在开始游戏之前运行一个非常快速的基准测试,以推断游戏运行的设备的性能。这将是很多工作,但在可以线性地扩展游戏性能的情况下,这样做可能是值得的。例如,你可以非常精细地定义绘制森林所使用的树的数量,比如,最大树数的 80%。

如果你使用了大量的粒子效果,通常就会出现这种情况。然后,非常容易调整你使用的粒子总数以匹配设备的性能。

性能限制 - 内存

现在我们能够检测到游戏在移动设备上运行,我们将能够适应设备的限制。谈论性能时,你脑海中可能首先浮现的是处理器的速度,但大多数情况下,内存是一个更大的限制。

在桌面上,你不再需要考虑内存,大多数情况下(除了避免内存泄漏)。在移动设备上,内存是一种更为有限的资源,有时,仅仅加载一个大图像对浏览器来说就太多了。例如,对于 iDevices,允许的最大图像尺寸如下:

< 256 MB 的 RAM > 256 MB 的 RAM
GIF、PNG 和 TIFF 图像 3 百万像素 5 百万像素
JPEG 32 百万像素 32 百万像素
Canvas DOM 元素 3 百万像素 5 百万像素

需要注意的是,这与图像的压缩毫无关系。事实上,尽管压缩图像以减少下载所需的时间对内存印记很重要,但唯一重要的是分辨率。

所以,如果压缩不会有所帮助,我们该怎么办呢?让我们以我们的多人在线角色扮演游戏为例。在那里,我们使用了一个非常大的图像,其中包含我们瓦片地图的所有图块。实际上,我们游戏中创建的地图并未使用许多这些图块。因此,减少这个非常大的图像的一个非常简单的方法是删除我们不需要的所有图块。

这意味着,您不再拥有一个整个游戏都会使用的大图像,而是为每个区域都有一个较小的图像。这将增加代码的复杂性,因为它意味着管理区域之间的过渡,但它有一个优点,即完全不会降低您的级别设计。

在某些情况下,即使使用这种技术,您可能会发现很难将图像的大小减小到足够小。一个简单的解决方案是为桌面和移动平台分别设置两个版本的级别。在移动版本中,您将减少图块的种类。例如,在我们的游戏中,我们使用多个图块来渲染草地,如下图所示:

性能限制 - 内存

在这里,我们可以简单地使用一个单一的图块。当然,生成的图形将会变得不那么多样化,但它将大大减少您所需的图块数量。然而,这种做法的缺点是需要您维护每个级别的两个单独版本。

性能限制 - 速度

移动设备的性能差异很大,但即使是最快的移动设备也比任何桌面设备都要慢得多。这意味着有些游戏根本无法在移动设备上运行,无论您付出多少努力。然而,有许多游戏可以稍加改造,使其以合理的速度运行。

制作基于 DOM 的游戏时,您可以加快速度的地方并不多。您应该做的第一件事是尝试减少精灵或图块的数量。

指定页面的可见区域

减少图块数量的一个非常简单的方法是使游戏区域更小。您可能会认为这是一个非常糟糕的主意,因为您真正想要的是游戏区域填满整个屏幕,这意味着要适应设备的分辨率。好吧,是的...也不是!是的,您希望游戏区域填满整个屏幕,但不,这并不一定意味着使用完整的分辨率。

移动浏览器提供了一个非常方便的meta属性,允许您指定浏览器应该如何管理页面宽度。这在这里将非常有用,因为我们基本上可以选择游戏区域的大小,然后强制浏览器将其显示在全屏模式下。

这个属性称为视口,要为屏幕指定一个给定的宽度,您可以简单地写:

<meta name="viewport" content="user-scalable=no, width=480" />

我们在这里配置了两种不同的行为。首先,我们告诉浏览器页面的原始宽度为 480 像素。假设设备的原生分辨率为 960 像素;这意味着页面将被放大。如果设备分辨率为 320 像素,页面将被缩小。

我们在这里做的第二件事是禁用用户的缩放功能。如果你想后续使用触摸事件来控制游戏,这是不必要的;为了控制游戏,你要确保用户在尝试操作游戏时不会放大或缩小页面。

细节级别

减少精灵的数量可能会很棘手。例如,你不希望减少游戏中的 NPC(非玩家角色)或敌人的数量。识别可以移除的元素是一项繁琐的任务。

以下图片摘自 第五章,透视。这是我们为我们的 RPG 使用的瓦片地图的结构的快速提醒。

细节级别

如果你将这个图中最后两层中纯装饰性的元素保留下来,减少精灵的数量就变得很容易;如果需要,只需删除这两层,就完成了。

这并不一定意味着你必须摆脱所有这些元素。你可以做的是有两个不同版本的这些层,一个有很多元素,一个元素更少。

如果你真的需要进一步减少精灵的数量,你将不得不考虑这将对游戏玩法产生的影响。这里没有标准答案;你将需要针对每个游戏进行独立的处理,并在保持游戏玩法与游戏速度之间找到正确的平衡。

触摸控制

到目前为止,我们只谈到了移动设备的问题部分,但是这些设备也带来了一些优势。触摸屏允许非常有趣的游戏机制(而且多点触摸屏效果更好)。

在这一部分,我们将实现两种不同的触摸控制方式,但这确实是一个可以发挥创意、找到新颖而引人入胜的方式让玩家与你的游戏进行交互的领域。重要的是要知道触摸控制的 API 不是标准的,而且移动设备可能会以一些不同的方式实现它。尽管如此,下一节中显示的代码应该可以在 iOS 和最新版本的 Android 上正常工作。

我们将实现的两个界面都基于同样的基本思想:整个屏幕都是一个摇杆,没有可见的 UI 元素被使用。这样做的优势是,用于控制的表面越大,控制就越精确。缺点是,如果用户不是通过简单地看屏幕就能发现它是如何工作的,你就需要解释给用户听。

我们使用的代码可以很容易地调整为适用于放置在屏幕底部/侧边的较小控件。

十字键

方向键(缩写为 D-pad)是一种在老式游戏机上使用的控制方式。它提供了几个预定义的方向供用户选择(例如,上、下、左和右)。相比之下,摇杆提供了一个模拟接口,玩家可以选择精确的方向(例如,30 度角度)。我们将要实现的第一个控制方法将屏幕划分为如下图所示的五个区域:

方向键

优点在于此方法与键盘控制有一一对应关系。如果玩家触摸区域,它将对应于按下键盘上的上箭头,其他边界区域类似。如果玩家触摸中心区域,它将对应于按下空格键。

要实现这一点,我们将创建五个虚拟键,并扩展检查键盘输入的代码部分以进行检查。下面的代码摘录是定义这些虚拟键的部分:

var UP = {
  on: false,
  id: 0
};
var DOWN = {
  on: false,
  id: 0
};
var LEFT = {
  on: false,
  id: 0
};
var RIGHT ={
  on: false,
  id: 0
};
var INTERACT ={
  on: false,
  id: 0
};

如您所见,这些键具有 ID 字段。这是必要的,因为我们正在处理多点触摸事件,我们必须能够识别哪些触摸事件结束时将on字段切换回false,玩家抬起手指时。

为了检测玩家触摸屏幕,我们将注册一个touchstart事件处理程序。这个事件类似于onmousedown事件,除了它包含一个触摸列表。这是有道理的,因为我们正在处理多点触摸输入,我们不能简单地假设只有一个手指触摸屏幕。

所有这些触摸都存储在event.changedTouches数组中。在您的事件处理程序中,您只需查看每个触摸。下面的代码摘录是整个事件处理程序:

document.addEventListener('touchstart', function(e) {
  if(gameStarted){
    e.preventDefault();
 for (var i = 0; i < e.changedTouches.length; i++){
      var touch = e.changedTouches[i]

       var x = touch.pageX - 480 / 2;
       var y = touch.pageY - 320 / 2;

       if (Math.abs(x) < 20 && Math.abs(y) < 20){
         INTERACT.on = true;
         INTERACT.id = touch.identifier;

       } else if (Math.abs(x) > 480 / 320 *  Math.abs(y)) {
         // left or right
         if(x > 0){
           RIGHT.on = true;
           RIGHT.id = touch.identifier;
         } else {
           LEFT.on = true;
           LEFT.id = touch.identifier;
         }
       } else {
         // up or down
         if(y > 0){
           DOWN.on = true;
           DOWN.id = touch.identifier;
         } else {
           UP.on = true;
           UP.id = touch.identifier;
         }
       }
     }
    }
}, false);

由于"jQuery 核心"不支持触摸事件,我们使用标准方法来注册事件处理程序。然后我们阻止事件冒泡,以确保它们不会产生缩放、滚动等。此事件处理程序的最后一部分检查每个触摸,以确定它在哪个区域,将相应按键的on标志切换为true,并设置正确的id值以进行跟踪。

现在我们需要能够检测触摸何时结束。这通过touchend事件完成。这个事件的工作方式类似于touchstart,事件处理程序的代码结构相同。在这里,我们不需要担心触摸的位置,而只需要关注其 ID。然后,我们将相应触摸的on标志切换回false

document.addEventListener('touchend', function(e) {
  if(gameStarted){
    e.preventDefault();

    for (var i = 0; i < e.changedTouches.length; i++){
        var touch = e.changedTouches[i]
        if (touch.identifier === UP.id){
         UP.on = false;
        } 
        if (touch.identifier === LEFT.id){
         LEFT.on = false;
        }
        if (touch.identifier === RIGHT.id){
         RIGHT.on = false;
        }
        if (touch.identifier === DOWN.id){
         DOWN.on = false;
        }
        if (touch.identifier === INTERACT.id){
         INTERACT.on = false;
        }
     }
  }
}, false);

现在我们的虚拟键持有正确的值,我们可以像使用保存真实键状态的数组一样在我们的代码中使用它们。下面的代码正是如此;修改部分已经突出显示:

var gameLoop = function() {
    var idle = true;

    if(gf.keyboard[37] || LEFT.on){ //left arrow
        player.left();
     idle = false;
    }
    if(gf.keyboard[38] || UP.on){ //up arrow
     player.up();
     idle = false;
    }
    if(gf.keyboard[39] || RIGHT.on){ //right arrow
        player.right();
        idle = false;
    }
    if(gf.keyboard[40] || DOWN.on){ //down arrow
     player.down();
     idle = false;
    }
    if(gf.keyboard[32] || INTERACT.on){ //space
        player.strike();
        idle = false;
    }
    if(idle){
        player.idle();
    }

    // ...
};

通过这些简单的修改,我们已经实现了我们的触摸控制的第一个版本。

模拟摇杆

之前的控制方法不错,但您可能想要让玩家以更自然的方式移动角色。这就是下面的方法发挥作用的地方。这里,我们只有两个区域:中心的一个小区域,它的作用类似于空格键,以及屏幕的其余部分。下图显示了这两个区域:

模拟摇杆

如果玩家触摸这个更大的区域,角色将朝触摸的方向移动。如果玩家的手指改变方向,角色的移动也会相应改变,如下图所示:

模拟摇杆

要实现这一点,我们稍微改变了玩家控制的方式,因此我们在player对象中添加了一个新方法:direction。该函数接受以度为单位的角度,并推断出最合适的动画,以及玩家的新位置。下面的代码显示了这个函数:

this.move = function(angle){
  if(state !== "strike"){
 var xratio = Math.cos(angle);
 var yratio = Math.sin(angle);
    if(Math.abs(xratio) > Math.abs(yratio)){
      if(xratio < 0){
        this.left();
      } else {
        this.right();
      }
    } else {
      if (yratio < 0){
        this.up();
      } else {
        this.down();
      }
    }
 moveX = 3*xratio;
 moveY = 3*yratio;
    }
};

这里只有一小段代码值得指出,如前面的片段所示。要从角度计算垂直和水平移动,我们使用正弦和余弦函数。它们的含义在下图中解释:

模拟摇杆

这两个函数将给我们一个介于-1 和 1 之间的数字,表示玩家应该沿每个轴移动多少。然后我们简单地将这个数乘以最大移动量(在我们的例子中为 3)来获得沿每个轴的实际移动。

我们不需要支持玩家尝试使用键盘和触摸屏控制游戏的情况,因为这种情况是非常不可能发生的。

事件处理程序

现在我们将使用一种与之前使用的虚拟键类似的模式,这里我们只会有两个。一个将与以前相同:交互键。第二个有点特殊,因为它将用于存储角度,该角度是角色应该移动的方向。

touchstart 事件处理程序与之前几乎相同,只是我们计算了触摸点和屏幕中心之间的角度:

document.addEventListener('touchstart', function(e) {
  if(gameStarted){
     for (var i = 0; i < e.changedTouches.length; i++){
       var touch = e.changedTouches[i];
       var x = touch.pageX - 480 / 2;
         var y = touch.pageY - 320 / 2;
       var radius = Math.sqrt(Math.pow(x,2)+Math.pow(y,2));

       if(radius < 30) {
         INTERACT.on = true;
         INTERACT.id = touch.identifier;
       } else if(!MOVE.on){
         MOVE.on = true;
         MOVE.id = touch.identifier;
         MOVE.angle = Math.atan2(y,x);
       }
     }
    }
}, false);

为此,我们使用另一个三角函数:余切。这个函数允许我们检索右角三角形的两条边之间的角度,如下图所示:

事件处理程序

touchend 处理程序与之前的处理程序相同,但适用于两个虚拟键。

document.addEventListener('touchend', function(e) {
  if(gameStarted){
     for (var i = 0; i < e.changedTouches.length; i++){
       var touch = e.changedTouches[i]
        if (touch.identifier === INTERACT.id){
         INTERACT.on = false;
        }
       if (touch.identifier === MOVE.id){
         MOVE.on = false;
        } 
     }
    }
}, false);

我们需要第三个事件处理程序来跟踪手指在触摸开始和结束之间的移动。此处理程序的结构与touchend的结构类似,但更新了MOVE虚拟键的角度:

document.addEventListener('touchmove', function(e) {
  if(gameStarted){
    e.preventDefault();
     for (var i = 0; i < e.changedTouches.length; i++){
       var touch = e.changedTouches[i];
       if (touch.identifier === MOVE.id){
         var x = touch.pageX - 480 / 2;
         var y = touch.pageY - 320 / 2;
         MOVE.angle = Math.atan2(y,x);
        } 
     }
    }
}, false);

通过这三个事件处理程序,我们实现了新的控制界面。您真的必须尝试它们,看看哪种方法更适合您。这些方法实际上只是许多其他方法中的两种,选择合适的方法将对您的游戏在移动设备上的成功产生重大影响,因此在选择最终方法之前,请毫不犹豫地尝试很多方法!

将我们的游戏与主屏幕集成

有一种非常优雅的方法可以使您的游戏在 iOS 上全屏运行。通过适当的配置,我们可以使您的游戏可安装到 SpringBoard 上。这将产生几个效果:游戏将在没有任何浏览器 UI 元素的情况下运行,并且将具有一个图标和一个启动画面。

所有这些都是通过在文档标头中设置一系列meta标签来完成的。

使您的游戏可安装

要使您的游戏可安装,您必须在文档头部使用apple-mobile-web-app-capable meta标签,并将值设置为yes。一旦完成这个步骤,玩家就可以从 Safari 将游戏添加到 SpringBoard,如下面的截图所示:

使您的游戏可安装

您应该在标头中拥有的代码如下所示:

<meta name="apple-mobile-web-app-capable" content="yes" />

以这种方式安装的网页将在没有任何可见浏览器 UI 元素(也称为 Chrome)的情况下运行。以下图列出了所有 UI 元素的名称:

使您的游戏可安装

遗憾的是,在撰写本文时,这个属性在安卓手机上的支持并不好。其中一些手机会将网页安装到主屏幕并使用自定义图标,但不接受无 Chrome 模式。其他手机将完全忽略它。

配置状态栏

一旦从 SpringBoard 启动,唯一剩下的 UI 元素就是状态栏。如前面的图所示,它是屏幕顶部的栏,显示诸如网络接收和名称以及剩余电量等信息。

您可以选择状态栏的外观,使其尽可能地适合您的应用程序。这可以通过apple-mobile-web-app-status-bar-style meta标签完成。

以下列表列出了您可以为此标签指定的可能值及其相应的效果:

  • default:如果您不使用这个meta标签或给它赋予这个值,则将状态栏的外观选择留给操作系统。

  • black: 使用这个值,状态栏将具有黑色背景和白色文本。

  • black-translucent: 使用这个值,状态栏将具有略带透明的黑色背景和白色文本。这个设置的特殊之处在于网页将被渲染在状态栏下面。这样做的好处是为游戏提供完整的设备分辨率;而使用其他设置,网页将在屏幕顶部丢失一些像素。

您应该在标头中拥有的代码如下所示:

<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

指定应用程序图标

如果您没有指定任何内容,iOS 将使用网页的屏幕截图作为图标。如果您想指定一个要替代使用的图标,则需要使用一个或多个link标签。问题在于不同的 iDevices 需要不同大小的图标。解决方案是在link标签中指定图标的大小,如下所示:

<link rel="apple-touch-icon" sizes="72x72" href="icon.png" />

可能的尺寸是:57 x 57、72 x 72、114 x 114 和 144 x 144。您使用此标签指定的图标将被覆盖上一种光泽效果。如果您希望您的图标原样使用,可以改用rel标签apple-touch-icon-precomposed

指定闪屏

当用户启动游戏时,页面加载期间将显示一个屏幕截图。如果您希望指定一张图像,可以使用一个带有rel标签apple-touch-startup-imagelink标签。

我们将遇到与图标相同的问题:每个设备都有另一个屏幕分辨率,应该使用相应的图像。但是,用于指定图像分辨率的方法与图标的方法不同。在这里,您需要使用media属性。

使用media属性,您可以使用device-width指定设备宽度,使用orientation指定设备方向,使用-webkit-device-pixel-ratio指定设备是否使用视网膜显示。完整的示例如下:

<link href="startup-image.png" media="(device-width: 320px) and (orientation: portrait) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image">

使用设备方向

在某些情况下,访问设备方向可能很有用。例如,您可以使用它来控制角色的移动。要做到这一点,您可以简单地注册一个事件处理程序,每当设备方向更改时就会收到一个事件。以下代码正是如此:

if(window.DeviceOrientationEvent) {
  window.addEventListener("deviceorientation", function(event){
    var alpha = event.alpha;
     var beta = event.beta;
     var gamma = event.gamma;
     // do something with the orientation
  }, false);
}

第一个if语句是用来检查设备是否支持设备方向 API 的。然后我们注册一个事件处理程序来访问设备的方向。这个方向由三个角度提供:alpha是绕 z 轴的旋转,beta是绕 x 轴的旋转,而gamma是绕 y 轴的旋转。

您已经知道 x 和 y 轴是什么;它们与我们用来定位游戏元素的轴相同。z 轴是一个指向玩家的屏幕外的轴。

以下图显示了这些轴及其相应的角度:

使用设备方向

使用离线应用程序缓存

移动设备的一个非常有用的功能是网页可以脱机工作。对于我们之前创建的平台游戏,这意味着一旦安装,您就再也不需要网络连接来加载游戏资产了。

要启用离线模式,您需要创建一个名为清单的文件。清单是游戏所需的所有文件的列表。它们将在春板上安装游戏时在设备上本地存储。

此清单的格式如下:

CACHE MANIFEST

CACHE:
tilesheet.png
level.json
gameFramework.js
rpg.js
jquery.js

NETWORK:
*

CACHE部分列出了所有要本地存储的文件。NETWORK部分列出了当应用程序在线时可访问的所有外部资源。如果您不想限制网络访问,可以像前面的示例中一样简单地写*

要将清单链接到您的游戏中,您将使用以下属性为您的html标记:

<html manifest="pathto/manifestFiles">

清单必须由服务器以 MIME 类型text/cache-manifest提供。

你必须意识到,一旦使用这样一个清单安装了应用程序,即使服务器上的应用程序发生了变化,游戏文件也不会被更新。强制刷新资源的唯一方法是更改清单本身。如果你不真的需要更改清单,你可以简单地在注释中写上版本号或时间戳;这就足够触发刷新。

另一种可能性是在静态媒体中添加版本号。这将有助于避免 iOS 中静态文件未能正确刷新的一些错误。

使用 Web 存储

然而,在一些情况下,你的应用程序需要将信息传输到服务器,例如,当玩家获得高分时。如果此刻游戏正在离线模式下运行,你该怎么办?

解决方案是使用 Web 存储。我们不会详细介绍你可以用 Web 存储做什么,但基本思想是在本地存储所有你想发送到服务器的信息,并在游戏再次在线时传输它。这项技术是 HTML5 规范的一部分,因此只有现代浏览器支持。你可以用它来保存数据的可用空间为 5MB,所以你必须明智地使用它。

要在客户端存储任何值,你可以简单地使用sessionStorage对象的setItem方法。要检索该值,你可以使用getItem方法。

以下代码正是显示这一点:

sessionStorage.setItem('key','value');
sessionStorage.getItem('key');

现在,如果你想检查游戏是否在线,你可以使用navigator对象上的onLine标志,如下所示:

if(navigator.onLine){
  // push data to the server
}

对于我们的 RPG 游戏来说,你可能希望在本地存储玩家位置和其击败的敌人,并在 Internet 连接恢复后将它们推送到服务器。

摘要

在本章中,你已经学习了许多仅适用于移动设备的特定 API 和技术。使用 Web 技术为移动设备编写游戏通常是一个挑战,但会极大地增加你的游戏潜在玩家数量。

甚至可以通过使用 PhoneGap(又名 Apache Cordova)在 App Store 上分发你的游戏。

在下一章中,我们将学习如何将声音和音乐添加到你的游戏中。使用 Web 技术来做这件事情可能有些麻烦,但它绝对是值得的!

第十章:发出一些声音

这是本书的最后一章,但这远非不重要的主题。音乐和音效是游戏用户体验的重要组成部分。合适的音乐可以完全改变关卡的感觉。合适的音效可以帮助玩家理解游戏的机制,或者给予他们在正确的时间执行正确操作所需的反馈。

此外,玩家期望在游戏中有声音,因为自从游戏诞生以来,声音一直存在于游戏中。不幸的是,当涉及到声音时,HTML 游戏存在一些大问题。您不能使用一个强大的解决方案使其能够在所有浏览器上添加声音并使其正常工作。

在本章中,我们将介绍四种不同的技术来为您的游戏添加声音:

  • 嵌入:这是在页面中包含声音的最古老的方法。在旧时代,它经常用于使页面播放 MIDI 文件作为背景音乐。它不是标准的,不提供一致的 JavaScript API,并且您无法保证支持给定的音频格式。不过,它被几乎所有您可以找到的浏览器支持。

  • HTML5 音频:您可以使用audio标签来产生声音。积极的一面是,几乎所有的浏览器都支持它。不利之处在于,您将不得不处理每个浏览器支持不同编解码器的事实,而且您将无法操纵声音。

  • Web 音频 API:这基本上是围绕 OpenAL 的 JavaScript 封装。这意味着您可以对声音做任何您想做的事情。遗憾的是,目前只有 Chrome 和 Safari(iOS 上也是如此)支持它。

  • Flash:可以使用 Flash 来播放声音。这可能看起来像一个奇怪的想法,因为我们在这里制作的是一个 JavaScript 游戏,但您通常可以将其用作旧浏览器的后备方案。

然后我们将看一些有趣的工具,您可以用来为您的游戏生成声音。

抽象音频

首先,让我们创建一个非常简单的库来抽象我们的框架与我们选择的音频实现之间的交互。以下代码代表了所有我们的实现都必须遵守的“契约”:

// a sound object
sound = function(){
  // Preloads the sound
  this.preload = function(url){
    // TODO: implement
  };

  // Returns true if the sound is preloaded
  this.isPreloaded = function(){
    // TODO: implement
  }

  // Starts to play the sound. If loop is true the
  // sound will repeat until stopped 
  this.play = function(loop){
    // TODO: implement
  };

  // Stops the sound
  this.stop = function(){
    // TODO: implement
  };
};

对于 Web 音频 API 的实现,我们将为我们的对象添加更多的功能,但这是您可能期望的任何音频库的基本功能。

使用我们的小型库

要在我们的游戏中使用声音,我们只需将相应的实现链接到我们的 HTML 文件中:

<script type="text/javascript" src="img/sound.js"></script>

现在我们将为我们的关卡添加背景音乐;我们需要设置声音并预加载它。我们将通过将initialize函数拆分为两个部分来完成此操作:

var initialize = function() {
    // ... 
    backgroundMusic = new sound();
    backgroundMusic.preload("background_music.mp3");
    waitForSound();
}

var waitForSound = function(){
  if (backgroundMusic.isPreloaded()){
    // ...
    backgroundMusic.play(true);
  } else {
    setTimeout(arguments.callee, 100);
  }
}

waitForSound函数检查声音是否已预加载。如果没有,我们创建一个超时以稍后再次检查其状态(准确地说,100 毫秒后)。正如您所见,一旦声音被预加载,我们就开始了级别并播放声音。现在,我们需要在级别完成时停止声音,如下面的代码所示:

var player = new (function(){
    // ...
    this.update = function () {
        if(status == "dead"){
           // ...
        } else if (status == "finished") {
          backgroundMusic.stop();
          // ...

当下一个级别开始时再次启动它:

var gameLoop = function() {
    if(gameState === "level"){
        // ..
    } else if (gameState === "menu") {

      if (gf.keyboard[32]){
        // ..
        backgroundMusic.play(true);
      }
    }
};

通过这些修改,如果声音库遵守我们刚刚指定的契约,我们将拥有背景音乐。现在让我们来看看针对此声音库的不同实现。

嵌入声音

HTML 具有一种非常方便的方法来将某些内容的阅读委托给插件:embed标签。这不是一个标准标签,但所有浏览器都支持它,并且被广泛用于在网站中包含 Flash。

这个相同的 HTML 标签可以用来在网页中包含声音。出于许多原因,这都不是一个理想的解决方案:

  • 没有标准的程序化方法来知道浏览器是否支持此功能。

  • 没有标准的方式来控制声音播放,因为暴露的 API 取决于用于播放声音的插件。尝试检测加载了哪个插件是可能的,但这个过程并不是非常可靠。此外,为每个可能的插件提供实现将是很多工作。

  • 支持的格式取决于已安装的插件,而不仅仅是浏览器。

  • 即使声音格式受支持,浏览器也可能要求允许启动插件。只要用户没有接受启动插件,就不会播放任何声音。

可能存在一些情况,其中使用此方法将声音包含到游戏中是合理的,但如果本章其余部分介绍的任何其他技术对您有效,我建议使用那些技术。

实施

让我们来看看负责预加载的部分的实现:

// Preloads the sound
this.preload = function(url){
  // Preloading is not supported in a consistant
  // way for embeded sounds so we just save the 
  // URL for later use.
  this.url = url;
};

// Returns true if the sound is preloaded
this.isPreloaded = function(){
  // Since we use no preloading we always return true
  return true;
}

使用embed标签实现预加载将需要知道用于播放声音的确切插件的知识。遗憾的是,这是不可能的。相反,我们选择创建一个完全通用的实现。作为副作用,我们不能支持预加载。上述代码简单地通过始终返回true来绕过预加载。

这造成了一个重大问题:文件只有在您想要播放它时才会开始加载。这意味着在调用play函数和播放器听到声音之间会有相当大的延迟。这对背景音乐来说不是什么大问题,但对于音效来说,这个时间几乎是毫无意义的。好的一面是,第二次播放声音时,它很可能已经被缓存,因此延迟应该会减少。

由于我们不想使用任何 JavaScript API 与插件交互,我们只需将embed标签注入页面并配置它自动开始播放。

// Starts to play the sound. If loop is true the
// sound will repeat until stopped 
this.play = function(loop){
  var embed = "<embed width='0' height='0' src='";
  embed += this.url;
  embed += "' loop='";
  embed += (loop)? "true" : "false";
  embed += "' autostart='true' />";
  this.obj = $(embed);
  $("body").append(this.obj);
};

我们存储生成的标签以便在stop方法中删除它:

// Stops the sound
this.stop = function(){
  this.obj.remove();
};

这样做的缺点是我们不会重用我们创建的标签。但是,由于您不会在需要创建大量声音的情况下使用此技术,这并不是一个大问题。

支持的格式

由于使用embed标签支持的格式列表取决于已安装的插件,无法保证某个文件可播放。但是,如果您使用 WAV 和 MIDI,应该是安全的。

如果您选择使用 WAV 文件,请注意,因为在此格式中,音频可以以许多不同的方式进行编码,为了最大限度地提高兼容性,您应该使用未压缩的波形。

HTML5 音频元素

为了匹配 Flash 的多媒体功能,HTML5 中添加了videoaudio元素。它们都配有相匹配的 JavaScript API,允许您使用 JavaScript 创建和操作视频或音频,而无需编写到文档中(就像Image对象允许您加载图像而无需使用img标签一样)。

首先,让我们快速看一下audio标签的外观:

<audio>
   <source src="img/backgroundMusic.ogg" type='audio/ogg; codecs="vorbis"'>
   <source src="img/backgroundMusic.mp3" type='audio/mpeg; codecs="mp3"'>
</audio>

正如您在这里所看到的,可以为audio标签提供多个来源。这是为了绕过此 API 的最大问题:文件格式的兼容性。事实上,即使所有现代浏览器都支持audio元素,也没有一种单一的音频格式可供您使用,所有这些浏览器都能识别。解决方法是提供多种格式。

这远非理想,因为它将强迫您在服务器上维护多个版本的音频文件。以下表格显示了现有音频格式与当前浏览器版本的兼容性:

MP3 AAC WAV Ogg Vorbis
Chrome
Firefox
Internet Explorer
Opera
Safari

这意味着如果您希望支持所有浏览器,您将至少需要提供两种文件格式。一致的意见是您应该选择 MP3 和 Ogg Vorbis(以.ogg结尾的音频文件)。

对于游戏,您通常不会使用 HTML 标签,而是直接使用 JavaScript API 进行工作。在我们开始之前,有一个小警告:尽管此标准的规范尚未最终确定,但大多数现代浏览器对此功能的支持相当好。由于标准在过去几年中发生了变化,某些较旧版本的当前浏览器可能具有略有不同的实现。

让我们看看如何在 JavaScript 中创建一个audio元素:

var audio = new Audio();

要了解浏览器可以使用 JavaScript 播放的格式,您可以使用canPlayType方法。基本用法将是:

var canPlay = audio.canPlayType('audio/ogg; codecs="vorbis"');

问题出现在此函数返回的可能值:"probably"、"maybe"、"no"和""。这可能远不如你期望的那样,但有一个非常好的理由:取决于格式,解码器在访问文件本身之前并不总是能确定是否支持它。这些值的含义如下:

  • "probably": 几乎可以确定是“是”!浏览器知道文件类型,并且相当确定它可以解码几乎所有这种类型的文件。

  • "maybe": 浏览器知道文件格式,但也知道不支持它的所有变体。另一个原因可能是浏览器将该文件的读取委托给插件,并且无法确定插件能处理这个特定的文件。

  • "": 浏览器对这种文件类型一无所知,也不会将阅读委派给插件。通过这个响应,你可以安全地假设这个文件不会被播放。

  • "no": 这与""相同;一些早期的标准实现使用了它。如果你想要支持更旧的浏览器,也应该期望这个响应。

有了这些知识,模仿我们之前看到的 HTML 代码的行为,你可以做像这样的事情:

var audio = new Audio();
var canPlayOggVorbis = audio.canPlayType('audio/ogg; codecs="vorbis"');
var canPlayMP3 = audio.canPlayType('audio/mpeg; codecs="mp3"');
if (canPlayOggVorbis == "probably" || (canPlayOggVorbis == "maybe" && canPlayMP3 != "probably")) {
  sound.ext = ".ogg";
} else {
  sound.ext = ".mp3";
} 

这给了 Ogg Vorbis 优先权,但在“可能”和“或许”之间更倾向于“可能”,因此如果浏览器可能只能或许播放 Ogg Vorbis,但认为可以可能播放 MP3,我们将加载文件的 MP3 版本。

预加载声音

embed标签相比,audio元素提供了管理声音预加载的方法,通过audio元素的readyState属性来完成。它有很多可能的值:

  • HAVE_NOTHING: 要么文件无法访问,要么到目前为止根本没有加载任何数据;可能是前者。这个状态对应的数字值是 0

  • HAVE_METADATA: 文件的开头部分已经预加载;这已经足够解析声音的元数据部分。有了这些数据,可以解析声音的持续时间。这个状态对应的数字值是 1

  • HAVE_CURRENT_DATA: 声音已经加载到当前播放位置,但还不足以继续播放。最有可能是由于播放位置是文件的结尾,因为通常情况下,状态转换非常快速到下面的文件。这个状态对应的数字值是 2

  • HAVE_FUTURE_DATA: 音频已经预加载足够,可以从给定的播放位置开始播放剩余的文件,但是不能保证播放不会很快停止以允许更多缓冲。这个状态对应的数字值是 3

  • HAVE_ENOUGH_DATA: 足够的声音已经预加载,所以声音应该在完全不中断的情况下播放(这是基于播放速率和下载速度的估计)。这个状态对应的数字值是 4

对于我们的实现,我们将只考虑在 HAVE_ENOUGH_DATA 状态下预加载的声音。让我们看看我们小型库的预加载实现:

// a sound object
sound = function(){

  // Preloads the sound
  this.preload = function(url){
    this.audio = new Audio();
    this.audio.preload = "auto";
    this.audio.src = url + sound.ext;
    this.audio.load();
  };

  // Returns true if the sound is preloaded
  this.isPreloaded = function(){
    return (this.audio.readyState == 4)
  }

  // ..
};

(function(){
 var audio = new Audio();
 var canPlayOggVorbis = audio.canPlayType('audio/ogg; codecs="vorbis"');
 var canPlayMP3 = audio.canPlayType('audio/mpeg; codecs="mp3"');
 if (canPlayOggVorbis == "probably" || (canPlayOggVorbis == "maybe" && canPlayMP3 != "probably")) {
 sound.ext = ".ogg";
 } else {
 sound.ext = ".mp3";
 }
})();

在前面的代码中有两部分;我们已经看到了突出显示的部分——它用于确定支持的声音格式。它被包装在一个只执行一次的函数中,并将支持的格式存储在 sound 对象中作为对象变量。

其余的代码是预加载的实现。首先我们创建一个 audio 对象。然后我们将预加载模式设置为 auto。这告诉浏览器它可以从文件中下载尽可能多的内容。之后,我们指向我们文件的正确版本。在这里,你可以看到 src 参数预计会省略扩展名,以便函数选择正确的版本。

最后,我们调用 load 函数。对于一些实现来说,这是必要的,才能开始加载文件。我们只有在 readyState 属性的值为 HAVE_ENOUGH_DATA 时才会考虑声音预加载。

播放和停止声音

控制播放很容易。让我们先看看我们的实现:

// Starts to play the sound. If loop is true the
// sound will repeat until stopped 
this.play = function(loop){
  if (this.audio.lopp === undefined){
    this.audio.addEventListener('ended', function() {
        this.currentTime = 0;
        this.play();
    }, false);
  } else {
    this.audio.loop = loop;
  }
  this.audio.play();
};

// Stops the sound
this.stop = function(){
  this.audio.pause();
 this.audio.currentTime = 0;
};

play 部分的实现非常直接。然而,一些旧版本的浏览器不支持 loop 属性。对于这些情况,我们需要手动循环。为了实现这一点,我们注册一个事件处理程序,当声音播放到结束时将被调用。这个事件处理程序简单地将声音倒回并再次播放。

正如你所看到的,audio 元素没有 stop 函数,但是有一个 pause 函数。这意味着如果我们在 pause 函数之后再次调用 start,声音将继续从原来的位置播放,而不会从头开始。为了倒带声音,我们将当前时间设置为 0,这意味着“从头开始”。

有一个 pause 函数可能会很方便,所以我们将在我们的库中添加一个。

// Pauses the sound
this.pause = function(loop){
  this.audio.pause();
};

现在你可能会认为这是一个相当好的解决方案,在大多数情况下,确实如此。然而,它还是存在一些问题;你不能在很大程度上操作声音,除了改变它的播放速度之外。效果、声道平移(控制声音在可用输出通道中的分配)等都不可能实现。此外,在某些设备上(主要是移动设备),你不能同时播放两个声音。大多数情况下,这是由于硬件限制,但结果是你不能同时拥有背景音乐和音效。如果你想在 iOS 上使用这个 API,你必须知道你只能在用户生成的事件响应中开始播放声音。

Web 音频 API

Web Audio API 的目标是给 JavaScript 开发人员基本上与编写本机应用程序时所用工具相同的工具。它复制了 OpenAL 的功能,OpenAL 是一种非常广泛使用的游戏开发 API。而且它是一个标准 API。不幸的是,目前它只在基于 Webkit 的浏览器上实现,包括 iOS 6 的移动版本。

在制定这一标准之前,Mozilla 在 Firefox 中添加了一个类似的 API,称为 Audio Data,并正在努力迁移到 Web Audio API。它可能会在 2013 年底之前的稳定版本中提供。至于 Internet Explorer,目前尚未公布任何信息。如果你想在 Firefox 中使用 Web Audio API,现在可以使用 audionode.js 库 (github.com/corbanbrook/audionode.js),但它并不完整,并且多年未更新。然而,如果你只是简单使用,它可能会起到作用!

这个 API 不仅提供了播放声音的方法,而且提供了生成声音效果的完整堆栈。这会导致 API 稍微复杂一些。

基本用法

Web Audio API 的理念是你连接节点以将声音路由到扬声器。你可以想象这些节点是真实的设备,比如放大器、均衡器、效果器或 CD 播放器。所有这些都是通过音频上下文(Audio context)完成的。它是一个实例化的对象,但你一次只能有一个实例。

让我们从一个非常基本的例子开始,将 MP3 源连接到扬声器,如下图所示:

基本用法

要创建一个 MP3 源,你首先需要加载声音。这是通过异步 XML HTTP 请求完成的。一旦完成,我们就有了一个编码为 MP3 的文件,我们需要对其进行解码以获得描述声波的字节并将其存储到缓冲区中:

var soundBuffer = null;
var context = new webkitAudioContext();

var request = new XMLHttpRequest();

request.open('GET', url, true);
request.responseType = 'arraybuffer';

// Decode asynchronously
request.onload = function() {
  context.decodeAudioData(request.response, function(buffer) {
    soundBuffer = buffer;
  }, onError);
}
request.send();

var context = new webkitAudioContext();

此时,soundBuffer 对象保存了解码后的声音数据。然后我们需要创建一个源节点并将其连接到缓冲区。比喻地说,这就像把 CD 放入 CD 播放器中一样:

var source = context.createBufferSource();
source.buffer = buffer;

最后,我们需要将源连接到扬声器:

source.connect(context.destination);

这就像将我们的 CD 播放器连接到耳机或扬声器一样。此时,你听不到任何声音,因为我们还没有播放声音。要做到这一点,我们可以写下以下内容:

source.start(0);

如果这个方法的名称最近更改为更容易理解,它以前称为 noteOn,所以你可能也想支持这个,因为这个更改是相当近期的,一些浏览器可能仍然实现了旧的名称。如果你想停止播放,你将调用 stop (或它的新名称 noteOff)。你可能想知道为什么我们需要向这个函数传递一个参数。因为这个 API 允许你以非常精确的方式同步音频,以便做任何你想做的事情(另一个声音或视觉效果)。你传递的值是声音应该开始播放(或停止)的时刻。这个值以秒为单位给出。

根据我们到目前为止所见到的,我们已经可以实现我们的小型库了,所以在我们看更复杂的用法之前,让我们先这样做吧:

sound = function(){
  this.preloaded = false;

  // Preloads the sound
  this.preload = function(url){
    var request = new XMLHttpRequest();
    request.open('GET', url, true);
    request.responseType = 'arraybuffer';

    // Decode asynchronously
    var that = this;
    request.onload = function() {
      sound.context.decodeAudioData(request.response, function(buffer) {
        that.soundBuffer = buffer;
        that.preloaded = true;
      });
    }
    request.send();
  };

  // Returns true if the sound is preloaded
  this.isPreloaded = function(){
    return this.preloaded;
  }

  // Starts to play the sound. If loop is true the
  // sound will repeat until stopped 
  this.play = function(loop){
    this.source = sound.context.createBufferSource();
 this.source.buffer = this.soundBuffer;
    this.source.connect(sound.context.destination);
    this.source.loop = true;
    this.source.start(0);
  };

  // Stops the sound
  this.stop = function(){
    this.source.stop(0);
  };
};

sound.context = new webkitAudioContext();

这里没有什么新的,除了 playstop 函数只能被调用一次。这意味着你每次想播放声音时都必须创建一个新的 bufferSource 对象。

连接更多节点

让我们向我们的上下文添加一个新的节点:一个 gain 节点。这个节点允许你改变你的声音的音量。这个声音的真实版本将是一个放大器。下图显示了我们的节点将如何连接:

连接更多节点

首先让我们创建节点:

var gainNode = context.createGainNode();

然后我们将我们的源连接到节点输入,将扬声器连接到节点输出:

source.connect(gainNode);
gainNode.connect(context.destination);

完成这件事之后,我们可以通过改变 gain.value 属性的值来修改音量,如下所示:

gainNode.gain.value = 0.8;

gain 参数是一种叫做 AudioParams 的东西。它是你会在许多节点中找到的一个参数,它拥有一系列函数,允许你不仅立即操纵一个值,还可以使它随着时间而改变。以下是你可以在这个对象上调用的函数:

  • setValueAtTime(value, time): 这将在指定的时间改变值。时间是以秒为单位的绝对时间,就像 start 函数一样。

  • linearRampToValueAtTime(value, time): 这将使当前值在提供的时间内线性变化,直到达到指定的值。

  • exponentialRampToValueAtTime(value, time): 这将使当前值从提供的时间到达指定值的时间内呈指数变化。

  • setTargetAtTime(target, time, constant): 这将使当前值以恒定速率从给定时间接近目标值。

  • setValueCurveAtTime(valuesArray, time, duration): 这将使值在提供的时间段内,根据提供的数组中的所有值进行过渡。

  • cancelScheduledValues(time): 这将取消从给定时间开始的所有预定值变化。

以下图示例显示了这些函数的示例:

连接更多节点

所有这些函数都可以设置成一个接一个地链式调用。它们之间的精确互动方式有时可能很复杂,一些过渡会产生错误。有关更多详细信息,请参阅规范。

加载多个声音

这个声音只是你可以用来创建声音图的众多可用节点中的一个。你可以随意组合它们,当然,也可以将多个源连接到你的context.destination对象上。如果你想使用多个声音,你会想要一次性预加载它们。

你可以使用我们之前看到的 API 来做到这一点,但是在 Web 音频中,通过使用BufferLoader,可以直接实现这一点。以下代码显示了这是如何工作的:

bufferLoader = new BufferLoader(
  context,
  [
    'sound1.mp3',
    'sound2.mp3'
  ],
  function(bufferList){
    // bufferList is an array of buffer
  }
);
bufferLoader.load();

当声音被缓冲时,回调将被执行,就像前面示例中的onload回调一样。

那么多节点,时间太少

这个 API 提供了相当多的效果节点;现在让我们快速概述一下这些节点。这个列表来自规范(www.w3.org/TR/webaudio/)。请记住,规范仍在发展中,实现并不总是完整的或与规范保持最新。

延迟节点

延迟节点只会延迟传入的声音。它只有一个参数,表示声音应该延迟多长时间。

延迟节点

脚本处理器节点

这个节点是一个通用的节点,允许你用 JavaScript 编写自己的效果。它有两个参数:

  • bufferSize:这定义了缓冲区的大小,它必须是以下值之一:256、512、1024、2048、4096、8192 或 16384。缓冲区是你的 JavaScript 函数将要处理的声音的部分。

  • onaudioprocess:这是将修改你的声音的函数。它将接收一个事件作为参数,具有以下属性:调用它的节点、输入缓冲区和从缓冲区播放音频的时间。函数将不得不将声音写入事件的输出缓冲区。

脚本处理器节点

定位器节点

这个节点将允许你在 3D 环境中对声音进行空间化处理。你可以使用setPositionsetOrientationsetVelocity函数定义声源的空间属性。要修改听者的空间属性,你将不得不访问context.listener对象并使用相同的函数。

你可以在这个节点上设置许多模式参数来微调空间化的方式,但是你需要查看规范以获取详细信息。

Panner node

卷积节点

这个节点创建一个卷积器效果(en.wikipedia.org/wiki/Convolution)。它接受两个参数:保存用作卷积的声波的缓冲区和一个布尔值,指定效果是否应该被归一化。

卷积节点

分析节点

此节点根本不改变声音;相反,它可以用于进行频率和时域分析。

分析节点

动态压缩器节点

此节点实现了一个压缩器效果。您可以使用以下参数配置效果:thresholdkneeratioreductionattackrelease

动态压缩器节点

双二次滤波器节点

此节点可用于应用一系列低阶滤波器。要指定使用哪一个,您可以使用节点的 type 属性将其分配给以下值之一:lowpasshighpassbandpasslowshelfhighshelfpeakingnotchallpass。您可以通过设置节点的一些属性来配置所选择的效果。有关详细信息,您可以查看规格。

WaveShaper 节点

此节点实现了一个波形整形器效果(en.wikipedia.org/wiki/Waveshaper),由节点的曲线属性中提供的整形函数数组定义。

Flash 回退

这可能听起来很奇怪,但有几种情况下您可能希望使用 Flash 进行声音处理。例如,您可能已经使用 HTML 设计了一个简单的游戏,因为您想同时面向 iOS 设备和台式机。但是您还希望旧版浏览器(如 IE 6)也具有声音。或者您希望仅使用 MP3 并为不支持 Flash 的设备提供 Flash。这些是一些情况,在这些情况下,如果不支持 HTML5 Audio 元素,则可能希望使用 Flash。

有一些库可以使您抽象化此过程;我们将详细查看其中之一——SoundManager 2——然后快速概述一些可用的替代方案。

SoundManager 2

要使用 SoundManager 2(www.schillmania.com/projects/soundmanager2/),您只需要在页面上包含一小段 JavaScript 代码,并提供指向 Flash 文件的链接(在同一服务器上托管以遵守同一来源策略)。让我们快速看一下预加载的实现将会是什么样子。

sound = function(){

  this.preloadStarted = false;

  // Preloads the sound
  this.preload = function(url){
    if(sound.ready){
      this.audio = soundManager.createSound({
        id: 'sound'+sound.counter++,
        url: url,
        autoLoad: true,
        autoPlay: false,
        volume: 50
      });
      this.preloadStarted = true;
    } else {
      this.url = url;
    }
  };

  // Returns true if the sound is preloaded
  this.isPreloaded = function(){
    if (!this.preloadStarted){
      this.preload(this.url);
      return false;
    } else {
      return (this.audio.readyState == 3)
    }
  }
  //...
};

sound.ready = false;
sound.counter = 0;
// a sound object
soundManager.setup({
 url: 'sm2.swf',
 flashVersion: 8, 
 useHTML5Audio: true,
 onready: function() {
 sound.ready = true;
 }
});

要使用 SoundManager 2,我们首先必须配置它;这是前面代码中突出显示的部分所做的。 url 参数是播放声音所使用的 Flash 文件的路径。我们选择了 Flash 版本 8,因为如果要模仿 HTML5 Audio 元素,则不需要更高版本。然后,我们设置一个标志,以在 Flash 不可用时使库使用 HTML5 播放声音。由于此方法可能需要一些时间才能加载和准备就绪,我们设置了一个事件处理程序来检测 SoundManager 对象是否已准备就绪。此事件处理程序仅设置一个标志。还有更多可用参数,我建议您在写得很好的 SoundManager 文档中查看它们。

要实现 preload 函数,我们必须考虑到 SoundManager 可能尚未准备好。如果是这种情况,我们等待下一次调用 isPreloaded 来开始预加载(如果此时 SoundManager 已准备就绪)。

要查询音频状态,我们可以使用 readyState 参数,但要小心;可用值与 HTML5 音频元素的值不同:

  • 0: 音频未初始化;预加载尚未开始

  • 1: 音频正在加载

  • 2: 加载音频时发生错误

  • 3: 文件已加载

很明显,如果 readyState 参数的值为 3,我们将认为音频已准备就绪。下面是最后三个方法的实现;这里没有特别之处,因为每个方法都与 SoundManager 中的一个精确匹配:

// Starts to play the sound. If loop is true the
// sound will repeat until stopped 
this.play = function(loop){
  this.audio.loops = loop;
  this.audio.play();
};

// Pauses the sound
this.pause = function(loop){
  this.audio.pause();
};

// Stops the sound
this.stop = function(){
  this.audio.stop();
};

这就是我们音频库的 SoundManager 实现了。

替代方案 SoundManager

有许多其他库可以完成 SoundManager 的功能。jPlayer (www.jplayer.org/) 就是其中之一。与 SoundManager 不同的是,它允许您播放视频,并且从一开始就围绕 HTML5 音频和视频元素构建,而这些后来才添加到 SoundManager。此外,它被构想为一个 jQuery 插件。但是,它被构想为媒体播放器,用户可以看到 UI。如果您想在游戏中使用它,可以禁用此功能。

另一种可能性是使用 SoundJS (www.createjs.com/#!/SoundJS)。它是 CreateJS 工具套件的一部分,非常适合游戏编程。SoundJS 支持 HTML5 音频、Web Audio API 和 Flash。如果您熟悉 CreateJS,使用它应该不是问题;否则,它可能会比前两种更难使用。我认为这值得付出努力,因为这是一个非常干净和现代的库。

如果您不想学习另一个播放音频的库,可以使用 mediaelement.js (mediaelementjs.com/);它为不支持 HTML5 音频和视频元素的浏览器提供了实现。如果使用此库,您只需使用 audio 元素编写代码,需要时将使用 Flash 或 Silverlight 脚本进行播放。

生成音效

到目前为止,我们大多讨论的是音乐。当然,相同的技术也可以用于播放音效。不过,处理它们的一个非常优雅的解决方案是:在运行时生成它们。这模仿了许多旧游戏主机上创建效果的方式。要在 JavaScript 中执行此操作,您可以使用 SFXR.js (github.com/humphd/sfxr.js)。它是受欢迎的 SFXR 的一个端口。不幸的是,它只能与 Firefox 的 Audio Data API 一起使用。尽管如此,我鼓励您去了解一下!

总结

你现在已经学会了使用标准 API、插件和 Flash 库在游戏中播放声音的许多不同方法,你的脑袋现在可能有些疼了!目前浏览器中的音频状态并不是很好,但是在几年后,当 Web Audio API 在所有浏览器中得到支持时,我们将处于一个更好的境地!因此,我建议花一些时间好好学习它,即使它比 HTML5 音频元素稍微复杂一些。

现在,你已经拥有了创建完美的 jQuery 游戏所需的所有工具!我真诚地希望你喜欢阅读这本书,并且它将激励你创造许多精彩的游戏。

posted @ 2024-05-19 20:13  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报