原文作者:Matthew Casperson • 编辑:Michele McDonough
原文链接: Game Development with JavaScript and the Canvas element
1、认识一下Canvas
2、在Canvas上绘图
3、通过Canvas元素实现高级图像操作
4、通过Canvas实现视差滚动
5、写一个游戏框架(一)
6、写一个游戏框架(二)
7、动画
8、JavaScript键盘输入
9、综合运用
10、定义级别
11、跳跃与坠落
12、添加道具
13、加载资源
14、添加主菜单
http://www.brighthub.com/internet/web-development/articles/38364.aspx
Canvas元素以及JavaScript引擎中新增的一些特性,让Web开发人员不必借助第三方插件,即可设计开发出精细且具有交互性的2D网页。这篇文章就向大家介绍一下Canvas元素,以及它的一些可能的用途。
HTML是为创建静态页面而生的。HTML所能实现的动态效果,也仅限于显示GIF动画和闪烁的文本。JavaScript改变了这一切,通过它能够动态修改网页。今天,很多Web服务都利用AJAX来创建网页,为用户提供更加流畅的体验,也超越了标准HTML页面中常见的“点击-重新加载-点击”式的交互模式。
然而,JavaScript的某些功能会受到其宿主浏览器的制约。尽管可以在网页中创建和修改任何元素,但JavaScript不能(轻易地)让浏览器显示一种新对象。通过JavaScript修改文本、插入图像或者缩放表格都很容易,因为这些对象本来就是HTML所支持的。如果你想再玩得刺激一点,比如写一个网页游戏,怎么办?那恐怕就得苦心积虑地改变标准HTML元素的用途,克服种种不测才能达到目的。要么,你就得求助于Flash或Silverlight这样的插件。
Canvas元素登场了。这个新HTML元素为JavaScript开发者提供了一种无需插件即可在网页中直接绘图的机制。Canvas元素最早是由苹果公司在其WebKit框架中引入的,Safari浏览器和Dashboard微件都在使用。Canvas元素现在也被建议加入了HTML5规范,得到了最新的Chrome、Firefox、Opera以及Konqueror等浏览器的支持。Internet Explorer(至少在IE8之前)还不支持Canvas,但ExplorerCanvas项目倒是为IE提供了与Canvas元素类似的功能。
Canvas元素对做过2D图形编程的人是小菜一碟。可以在这个元素上画线、画各种形状、画渐变,甚至可以利用与其他2D API中类似的函数来修改其中的每一个像素。得益于Chrome的V8、Firefox的SpiderMonkey以及Safari的Nitro等最新JavaScript引擎的性能,创建精细且具有交互性的Web应用已经完全没有问题。
我们这一系列文章将教会大家使用JavaScript和Canvas元素创建一个简单的平台游戏。将要涉及的内容包括动画、加载资源、分层渲染、滚动和交互。通过一步一步地展示示例代码和实际效果,你可以很快学会如何驾驭强大的Canvas元素。
http://www.brighthub.com/internet/web-development/articles/38744.aspx
下面,我们就通过一个循序渐进的示例及实时演示,来介绍如何使用JavaScript在Canvas元素上绘图及实现动画。
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"> <html lang="en"> <head> <title>JavaScript Platformer 1</title> <script type="text/javascript" src="jsplatformer1.js"></script> <style type="text/css"> body { font-family: Arial,Helvetica,sans-serif;} </style> </head> <body> <p> <a href="http://www.brighthub.com/internet/web-development/articles/38364.aspx"> Game Development with Javascript and the canvas element </a> </p> <canvas id="canvas" width="600" height="400"> <p>Your browser does not support the canvas element.</p> </canvas> </body> </html>这些HTML代码很直观。其中有两个重要的元素。
<script type="text/javascript" src="jsplatformer1.js"></script>这里包含的是将会修改Canvas元素的JavaScript代码,对应的Canvas元素的标记如下:
<canvas id="canvas" width="600" height="400"> <p>Your browser does not support the canvas element.</p> </canvas>
以上代码创建了一个Canvas元素。不支持Canvas的浏览器,比如Internet Explorer(IE8之前的版本),会忽略这个元素,而只显示其子元素。在这个简单的例子中,这个子元素就是一个段落,其中的文本告诉用户他们的浏览器不支持Canvas元素。而对于那些支持Canvas元素的浏览器,如Chrome、Opera和Firefox,则会忽略Canvas元素的子元素。
这个Canvas元素的ID属性很重要,因为后面的JavaScript将通过它来取得对该元素的引用。而width和height属性指定了画布的宽度和高度,这两个属性跟table或img等其他HTML元素中的同名属性作用一样。
以下是 jsplatformer1.js的代码:
//每秒钟target帧 const FPS = 30; var x = 0; var y = 0; var xDirection = 1; var yDirection = 1; var image = new Image(); //建议读者将图片下载到本地加载(经测试,此图片响应头部的Content-Type为application/empty,浏览器无法识别) image.src = "http://javascript-tutorials.googlecode.com/files/jsplatformer1-smiley.jpg"; var canvas = null; var context2D = null; window.onload = init; function init(){ canvas = document.getElementById('canvas'); context2D = canvas.getContext('2d'); setInterval(draw, 1000/FPS); } function draw(){ context2D.clearRect(0, 0, canvas.width, canvas.height); context2D.drawImage(image, x, y); x += 1* xDirection; y += 1* yDirection; if (x >= 450) { x = 450; xDirection = -1; }else if(x <= 0){ x = 0; xDirection = 1; } if (y >= 250) { y = 250; yDirection = -1; }else if(y <= 0){ y = 0; yDirection = 1; } }
如果只是一个Canvas元素,也没有什么用。JavaScript必须要在这块画布上面画点什么,相应的代码保存在 jsplatformer1.js中。简单来说,JavaScript在这里先加载了一幅图像,然后将其画在画布上面,最后让它在画布上移动。
首先,定义一些全局变量。
const FPS = 30;FPS定义的是画布重绘的频率。
var x = 0; var y = 0; var xDirection = 1; var yDirection = 1;变量x、y、xDirection和yDirection用于定义图像(相对于画布左上角)的位置,以及它在任意一时刻移动的方向。
var image = new Image(); image.src = "http://javascript-tutorials.googlecode.com/files/jsplatformer1-smiley.jpg";要把图像画到画布上,必须先加载一幅图像。为此,我们创建一个Image对象,将其src属性设置为一幅图像文件的URL(建议把图片下载到本地。——译者注)。
var canvas = null; var context2D = null;我们还需要取得对Canvas元素以及绘图上下文(稍后再详细介绍绘图上下文)的引用。稍后我们会把正确的值赋给这两个变量,现在先把它们设置为null。
window.onload = init;
最后,当页面加载完成后,我们必须知道立即运行绘制画布的代码;因此,在window对象的onload事件发生时,立即调用init函数。
function init(){ canvas = document.getElementById('canvas'); context2D = canvas.getContext('2d'); setInterval(draw, 1000/FPS); }
页面加载完毕后就会调用上面这个init函数。在这个函数中,我们先通过在HTML文件中指定的ID属性取得画布元素(毫无疑问,除了把它叫做画布,还能叫个啥?),然后再取得这个画布的2D绘图上下文对象。
上下文对象用于定义如何在画布上绘图。顾名思义,2D上下文嘛,支持在画布上绘制2D图形、图像和文本。支持画布元素的浏览器都支持2D上下文,除了2D上下文,还有其他试验性的上下文对象。Opera有一个专门为游戏设计的2D上下文,而Mozilla则有一个能够显示3D场景的上下文。可惜呀,目前这些上下文对象只有特定的浏览器才支持。如果你想用画布来创建Web应用,最好还是只使用常见的2D上下文。
因为我们在这里是想绘制一幅能移动的图像,所以必须建立渲染循环(render loop)。所谓渲染循环,实际上就是一个被重复调用的函数,渲染循环的每一次迭代,(在这个例子中)都可以让图像在屏幕上产生一点位移,如此循环往复就能给人图像在移动的感觉。为此,我们调用了setInterval函数,它的第一个参数是应该被重复调用的函数,这里的函数名是draw。setInterval函数的第二个参数指定调用函数的频率。这个参数值的单位是毫秒,而用1000除以早先定义的FPS得到的就是每次调用之间相隔的毫秒数。
这里需要注意一下,虽然我们指定每秒钟调用30次draw函数,但实际上不会调用30次。多长时间调用一次draw函数,取决于底层JavaScript引擎的速度和要执行的draw函数代码的复杂程度。如果系统很慢的话,很可能每秒钟只能调用一次draw函数。所以说,这里指定给setInterval的频率只是一种最理想的情况。
在画布上绘图的操作实际上都是由draw函数来完成的。下面我们就一步一步地说明其中的绘图操作。
context2D.clearRect(0, 0, canvas.width, canvas.height);所有绘图操作都是在上下文对象上发生的,并不是在画布元素上发生的。这里首先清空上下文,以便为绘制每一帧画面准备一个干净的版面。
context2D.drawImage(image, x, y);紧接着,就把图像绘制到上下文对象中,参数x和y指定了绘制图像的左上角坐标。
x += 1 * xDirection; y += 1 * yDirection;为了让图像在画布上移动,需要根据xDirection和yDirection是等于1(向右或向下)还是等于-1(向左或向上),来递增或递减x与y的值。
if (x >= 450){ x = 450; xDirection = -1; } else if (x <= 0) { x = 0; xDirection = 1; } if (y >= 250) { y = 250; yDirection = -1; } else if (y <= 0) { y = 0; yDirection = 1; }
如果图像移动到了画布外面,则反转图像的移动方向。我们知道图像的大小是150×150像素,而画布的大小的是600×400像素,因而就有了450(600 – 150)和250(400 – 150)这两个值。
最后的效果就是笑脸图像会在画布的范围内反弹往复。此时此刻,有读者可能会想:同样的效果如果通过修改DIV元素的位置来实现可能更容易一些。这一点我不否认。但这个例子只演示了画布元素所能实现的简单效果。下一篇文章我们就会介绍使用画布元素能够实现的高级效果,同样的效果若采用其他方式,恐怕就要困难多了。
http://www.brighthub.com/internet/web-development/articles/39509.aspx
这篇文章将带领大家学习使用JavaScript和Canvas元素操作图像了几种不同的方式,这些方式在Canvas元素出现之前是不可能的事儿。
上一篇文章演示了如何利用Canvas实现一个基本的图像动画。那个例子很简单,同样的效果通过修改IMG或DIV等标准HTML元素的一些属性,照样也可以轻易实现。下面我们就来演示一下画布元素的高级应用,展示一下它的真正威力。
首先,还是准备一个HTML页面。
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"> <html lang="en"> <head> <title>JavaScript Platformer 2</title> <script type="text/javascript" src="jsplatformer2.js"></script> <style type="text/css"> body { font-family: Arial,Helvetica,sans-serif;} </style> </head> <body> <p> <a href="http://www.brighthub.com/internet/web-development/articles/38364.aspx"> Game Development with Javascript and the canvas element </a> </p> <canvas id="canvas" width="600" height="400"> <p>Your browser does not support the canvas element.</p> </canvas> <br /> <button onclick="currentFunction=alpha;">Change Alpha</button> <button onclick="currentFunction=shear;">Shear</button> <button onclick="currentFunction=scale;">Scale</button> <button onclick="currentFunction=rotate;">Rotate</button> </body> </html>与上个一例子的HTML页面相比,唯一的区别就是添加了一些按钮。单击这些按钮,就会设置currentFunction变量(稍后介绍)的值,用以改变在渲染循环中运行的函数。
// 每秒多少帧 const FPS = 30; const SECONDSBETWEENFRAMES = 1 / FPS; const HALFIMAGEDIMENSION = 75; const HALFCANVASWIDTH = 300; const HALFCANVASHEIGHT = 200; var image = new Image(); image.src = "jsplatformer2-smiley.jpg"; //还是第一个例子中的图像 var canvas = null; var context2D = null; var currentFunction = null; var currentTime = 0; var sineWave = 0; window.onload = init; function init() { canvas = document.getElementById('canvas'); context2D = canvas.getContext('2d'); setInterval(draw, SECONDSBETWEENFRAMES * 1000); currentFunction = scale; } function draw() { currentTime += SECONDSBETWEENFRAMES; sineWave = (Math.sin(currentTime) + 1) / 2; context2D.clearRect(0, 0, canvas.width, canvas.height); context2D.save(); context2D.translate(HALFCANVASWIDTH - HALFIMAGEDIMENSION, HALFCANVASHEIGHT - HALFIMAGEDIMENSION); currentFunction(); context2D.drawImage(image, 0, 0); context2D.restore(); } function alpha() { context2D.globalAlpha = sineWave; } function shear() { context2D.transform(1, 0, (sineWave - 0.5), 1, 0, 0); } function scale() { context2D.translate(HALFIMAGEDIMENSION * (1 - sineWave), HALFIMAGEDIMENSION * (1 - sineWave)); context2D.scale(sineWave, sineWave); } function rotate() { context2D.translate(HALFIMAGEDIMENSION, HALFIMAGEDIMENSION); context2D.rotate(sineWave * Math.PI * 2); context2D.translate(-HALFIMAGEDIMENSION, -HALFIMAGEDIMENSION); }
跟前面一样,这个JavaScript文件先定义了一些全局变量。
然后,跟前面一样,要设置在window的onload事件发生时立即调用init函数(关于init函数的介绍,请参见上一篇文章)。
下面来看一看draw函数:
function draw() { currentTime += SECONDSBETWEENFRAMES; sineWave = (Math.sin(currentTime) + 1) / 2; context2D.clearRect(0, 0, canvas.width, canvas.height); context2D.save(); context2D.translate(HALFCANVASWIDTH - HALFIMAGEDIMENSION, HALFCANVASHEIGHT - HALFIMAGEDIMENSION); currentFunction(); context2D.drawImage(image, 0, 0); context2D.restore(); }
这个例子要演示4种效果:修改alpha值(透明度),以及缩放、旋转和切变图像。为了展示这些效果,需要基于某一范围内的值来应用变化。变量sineWave就用来定义这个范围值的基准。
标准的正弦函数能够在-1到1之间产生非常完美的波形图。首先,我们通过递增currentTime变量来反映动画已经运行了多长时间,然后再利用这个值在正弦曲线上找到一个点。给正弦函数返回的值(从-1到1)先加1再除以2,就可以把它们转换成0到1这个范围内的值。
currentTime += SECONDSBETWEENFRAMES; sineWave = (Math.sin(currentTime) + 1) / 2;然后,调用clearRect方法清空画布,以便为后面的绘图准备一个干净的版面。
context2D.clearRect(0, 0, canvas.width, canvas.height);
应用到画布上面的效果是可以累积的,因而就可以利用几个简单的函数来“组合”出效果来。例如,在向屏幕上绘制之前,可能会有一艘飞船需要旋转、变换和缩放。因为所有效果都对画布起作用,所以这些效果会应用到将被绘制在屏幕上的所有对象,而不仅仅是某一幅图像或某一个形状(比如一艘飞船)。
其中,save和restore函数为应用这些累积的效果提供了一种简单的机制,可以将应用了这些效果的图像或图形绘制到画布上,然后“撤销”这些改变。后台的操作是什么呢?save函数把当前的绘制状态推进栈里,而restore函数则把最后一个状态弹出栈。还拿前面提到的飞船为例,需要执行下列操作:
在这里,我们就是要组合起来使用这两个方法。首先,在把任何效果应用到画布之前,先保存绘制状态。
context2D.save();保存了绘制状态之后,就该应用目标效果了。为此,首先调用translate函数,从而将随后要绘制的图像在画布上居中。
context2D.translate(HALFCANVASWIDTH - HALFIMAGEDIMENSION, HALFCANVASHEIGHT - HALFIMAGEDIMENSION);接下来,调用由变量currentFunction引用的函数。正是这些被引用的函数,是让图像发生alpha(透明度)变化以及缩放、旋转和切变的关键。这些函数我们稍后再介绍。
currentFunction();为图像应用完效果之后,就可以把它绘制到画布上面了。所以,接下来就是调用drawImage来绘图。
context2D.drawImage(image, 0, 0);最后,再调用restore函数,把自调用save函数以来应用的所有效果从画布上移除。
context2D.restore();
function alpha() { context2D.globalAlpha = sineWave; }
通过修改上下文对象的globalAlpha属性,所有后续绘制操作的透明度都会被修改。将globalAlpha设置为0,意味着被绘制的任何对象都将完全透明,而将这个属性设置为1,则意味着任何绘制操作都会保持原有的透明度级别。在此,我们通过修改这个globalAlpha属性,可以实现笑脸的淡入和淡出效果。
function shear() { context2D.transform(1, 0, (sineWave - 0.5), 1, 0, 0); }
切变操作是通过transform函数向画布应用一个矩阵来实现的。变换矩阵本身就是一个值得研究的主题,但对我们来说,如果不想理解背后的数学原理,可以在网上找到很多标准的2D变换矩阵(http://en.wikipedia.org/wiki/Transformation_matrix#Examples_in_2D_graphics),直接使用transform函数来应用它们即可。所谓切变,其实就是把图像的顶部或底部推到一边。
function scale() { context2D.translate(HALFIMAGEDIMENSION * (1 - sineWave), HALFIMAGEDIMENSION * (1 - sineWave)); context2D.scale(sineWave, sineWave); }
顾名思义,scale(缩放)函数修改的是图像的大小。但在此之前,我们还调用了一次transalte函数。这是为了让缩放后的图像在画布上居中。如果你把这行代码注释掉,就会发现图像会从左上角向右下角膨胀。调用translate函数就是为抵消其圆心的位移,让图像始终居中。
function rotate() { context2D.translate(HALFIMAGEDIMENSION, HALFIMAGEDIMENSION); context2D.rotate(sineWave * Math.PI * 2); context2D.translate(-HALFIMAGEDIMENSION, -HALFIMAGEDIMENSION); }
与scale函数类似,rotate(旋转)函数的作用也正如其名:旋转图像。与scale函数同样类似的是,这里也额外调用了translate函数以确保图像围绕中心点而不是左上角旋转。建议大家把对translate函数的调用注释掉,自己看一看结果有什么不同。
刚刚我们看到了使用画布元素实现的4种也还算简单的效果,这些效果使用标准的HTML元素几乎是不可能做到的。其中,有的效果可以使用scale和rotate等内置函数来实现,而使用transform函数则可以完成大量的图像操作(切变只是其中之一)。
看看Demo吧。http://webdemos.sourceforge.net/jsplatformer2/jsplatformer2.html
http://www.brighthub.com/internet/web-development/articles/40512.aspx
在知道了如何使用画布元素之后,接下来我教大家写一个框架,有了这个框架,我们就可以把它作为基础来创建游戏。在这第一部分,我们会介绍前两个文件/类。
编写代码之前,我们先来看一看随后几篇文章将致力于创建的示例Demo。表面上看起来,这个Demo跟第二篇文章里的那个没啥区别,但如果你看看后台(查看网页源代码)就会发现,为了更方便地创建这个最终效果,一个凝聚不少心血的基础框架已经写好了。
下面我们要介绍的JavaScript代码使用面向对象的方式来编写。对于没有编写过多少JavaScript代码的人来说,恐怕第一眼看到它们会觉得有点奇怪。如果你真的不太熟悉JavaScript的面向对象编程,建议通过Mozilla Developer Network的这个教程https://developer.mozilla.org/en/Introduction_to_Object-Oriented_JavaScript来补补课。这篇教程里解释了我们稍后会用到的一些编程技术。
从设计思想上来看,这个框架可以分成两部分:与底层的2D引擎交互的类(用于操作画布、控制渲染循环、处理输入等的代码)和用来创建对象以便构成游戏的类。前者可以归为引擎类,后者可以归为应用类。由于应用类要构建于引擎类之上,所以我们需要先来创建引擎类。
如果你研究了前面例子中的代码,就会发现Main.js文件中包含了不少代码。
/** 每秒多少帧 @type Number */ var FPS = 30; /** 两帧间间隔的秒数 @type Number */ var SECONDS_BETWEEN_FRAMES = 1 / FPS; /** GameObjectManager 实例的全局引用 @type GameObjectManager */ var g_GameObjectManager = null; /** 应用中用到的图像 @type Image */ var g_image = new Image(); g_image.src = "jsplatformer3-smiley.jpg"; // 将应用的入口设置为init函数 window.onload = init; /** 应用的入口 */ function init() { new GameObjectManager().startupGameObjectManager(); }
首先是定义全局变量的代码。然后,跟以前一样,当页面加载完毕后立即运行init函数。在init函数里,创建GameObjectManager类的实例。
这里在GameObjectManager类的实例上调用了startupGameObjectManager函数。这篇文章以及后面的几篇文章还将多次提到几个命名上具有startupClassName形式的函数。这些函数实际上充当了各自类的构造函数,这样做有两个原因。
首先,JavaScript不支持函数重载(至少不容易实现)。如果你想让一个类有多个构造函数,那么这就成了问题。而通过把构造工作分配给另一组函数(如startupClassName1、startupClassName2),就可以比较容易地定义构造类的不同方式了。
第二个原因(很大程度上也是个人的问题)是我经常会在构造函数中引用尚未定义的变量。这可能是我使用C++、Java和C#这些语言落下的毛病,在这些语言里,类变量在源代码中的位置对其在构造函数中的可见性没有影响。拿下面这个C#类为例:
class Test { public void Test() {this.a = 5;} public int a; }这些代码是合乎语法的,可以正常工作。下面再看看JavaScript中一个相同的例子:
function Test() { this.a = 5; var a; }
这段代码的问题在于,局部变量a在我们把数值5赋给它的时候还不存在。只有运行到var a;这一行,变量a才存在。尽管这个例子有点故意编排的意味,但的确能够说明我所遇到的问题。通过把类的创建放到一个类似startupClassName这样的函数中完成,并且在构造函数中定义(但不初始化)局部变量,然后当我在这些构建函数中引用相应的局部变量时,就能够确保它们一定是存在的。
/** 管理游戏中所有对象的管理器 @class */ function GameObjectManager() { /** 保存游戏中对象的数组 @type Arary */ this.gameObjects = new Array(); /** 上一次帧被渲染的时间 @type Date */ this.lastFrame = new Date().getTime(); /** x轴的全局滚动值 @type Number */ this.xScroll = 0; /** y轴的全局滚动值 @type Number */ this.yScroll = 0; /** 对ApplicationManager实例的引用 @type ApplicationManager */ this.applicationManager = null; /** 对画布元素的引用 @type HTMLCanvasElement */ this.canvas = null; /** 对画布元素2D上下文的引用 @type CanvasRenderingContext2D */ this.context2D = null; /** 对内存中用作后台缓冲区的画布的引用 @type HTMLCanvasElement */ this.backBuffer = null; /** 对后台缓冲画布的2D上下文的引用 @type CanvasRenderingContext2D */ this.backBufferContext2D = null; /** 初始化这个对象 @return A reference to the initialised object */ this.startupGameObjectManager = function() { // 设置引用this对象的全局指针 g_GameObjectManager = this; // 取得画布元素及其2D上下文的引用 this.canvas = document.getElementById('canvas'); this.context2D = this.canvas.getContext('2d'); this.backBuffer = document.createElement('canvas'); this.backBuffer.width = this.canvas.width; this.backBuffer.height = this.canvas.height; this.backBufferContext2D = this.backBuffer.getContext('2d'); // 创建一个新的ApplicationManager this.applicationManager = new ApplicationManager().startupApplicationManager(); // 使用setInterval来调用draw函数 setInterval(function(){g_GameObjectManager.draw();}, SECONDS_BETWEEN_FRAMES); return this; } /** 渲染循环 */ this.draw = function () { // 计算从上一帧到现在的时间 var thisFrame = new Date().getTime(); var dt = (thisFrame - this.lastFrame)/1000; this.lastFrame = thisFrame; // 清理绘制上下文 this.backBufferContext2D.clearRect(0, 0, this.backBuffer.width, this.backBuffer.height); this.context2D.clearRect(0, 0, this.canvas.width, this.canvas.height); // 首先更新所有游戏对象 for (x in this.gameObjects) { if (this.gameObjects[x].update) { this.gameObjects[x].update(dt, this.backBufferContext2D, this.xScroll, this.yScroll); } } // 然后绘制所有游戏对象 for (x in this.gameObjects) { if (this.gameObjects[x].draw) { this.gameObjects[x].draw(dt, this.backBufferContext2D, this.xScroll, this.yScroll); } } // 将后台缓冲复制到当前显示的画布 this.context2D.drawImage(this.backBuffer, 0, 0); }; /** 向gameObjects集合中添加一个GameObject @param gameObject The object to add */ this.addGameObject = function(gameObject) { this.gameObjects.push(gameObject); this.gameObjects.sort(function(a,b){return a.zOrder - b.zOrder;}) }; /** 从gameObjects集合中删除一个GameObject @param gameObject The object to remove */ this.removeGameObject = function(gameObject) { this.gameObjects.removeObject(gameObject); } }
首先看一看GameObjectManager类。GameObjectManager是一个引擎类,用于管理画布的绘制操作,还负责分派GameObject类(下一篇文章里介绍)的事件。
GameObjectManager类的startupGameObjectManager函数的代码如下:
/** 初始化这个对象 @return A reference to the initialised object */ this.startupGameObjectManager = function() { // 设置引用this对象的全局指针 g_GameObjectManager = this; // 取得画布元素及其2D上下文的引用 this.canvas = document.getElementById('canvas'); this.context2D = this.canvas.getContext('2d'); this.backBuffer = document.createElement('canvas'); this.backBuffer.width = this.canvas.width; this.backBuffer.height = this.canvas.height; this.backBufferContext2D = this.backBuffer.getContext('2d'); // 创建一个新的ApplicationManager this.applicationManager = new ApplicationManager().startupApplicationManager(); // 使用setInterval来调用draw函数 setInterval(function(){g_GameObjectManager.draw();}, SECONDS_BETWEEN_FRAMES); return this; }
前面已经说过,我们会把每个类的初始化工作放在startupClassName函数中来做。因此,GameObjectManager类将由startupGameObjectManager函数进行初始化。
而引用这个GameObjectManager实例的全局变量g_GameObjectManager经过重新赋值,指向了这个新实例。
// 设置引用this对象的全局指针 g_GameObjectManager = this;对画布元素及其绘图上下文的引用也同样保存起来:
// 取得画布元素及其2D上下文的引用 this.canvas = document.getElementById('canvas'); this.context2D = this.canvas.getContext('2d');
在前面的例子中,所有绘图操作都是直接在画布元素上完成的。这种风格的渲染一般称为单缓冲渲染。在此,我们要使用一种叫做双缓冲渲染的技术:任意游戏对象的所有绘制操作,都将在一个内存中的附加画布元素(后台缓冲)上完成,完成后再通过一次操作把它复制到网页上的画布元素(前台缓冲)。
双缓冲技术(http://www.brighthub.com/internet/web-development/articles/11012.aspx)通常用于减少画面抖动。我自己在测试的时候从没发现直接向画布元素上绘制有抖动现象,但我在网上的确听别人念叨过,使用单缓冲渲染会导致某些浏览器在渲染时发生抖动。
不管怎么说,双缓冲还是能够避免最终用户看到每个游戏对象在绘制过程中最后一帧的组合过程。在通过JavaScript执行某些复杂绘制操作时(例如透明度、反锯齿及可编程纹理),这种情况是完全可能发生的。
使用附加缓冲技术占用的内存非常少,多执行一次图像复制操作(把后台缓冲绘制到前台缓冲)导致的性能损失也可以忽略不计,可以说实现双缓冲系统没有什么缺点。
如果将在HTML页面中定义的画布元素作为前台缓冲,那就需要再创建一个画布来充当后台缓冲。为此,我们使用了document.createElement函数在内存里创建了一个画布元素,把它用作后台缓冲。
this.backBuffer = document.createElement('canvas'); this.backBuffer.width = this.canvas.width; this.backBuffer.height = this.canvas.height; this.backBufferContext2D = this.backBuffer.getContext('2d');接下来,我们创建了ApplicationManager类的一个新实例,并调用startupApplicationManager来初始化它。这个ApplicationManager类将在下一篇文章中介绍。
// 创建一个新的ApplicationManager this.applicationManager = new ApplicationManager().startupApplicationManager();最后,使用setInterval函数重复调用draw函数,这个函数是渲染循环的核心所在。
// 使用setInterval来调用draw函数 setInterval(function(){g_GameObjectManager.draw();}, SECONDS_BETWEEN_FRAMES);下面来看一看draw函数。
/** 渲染循环 */ this.draw = function () { // 计算从上一帧到现在的时间 var thisFrame = new Date().getTime(); var dt = (thisFrame - this.lastFrame)/1000; this.lastFrame = thisFrame; // 清理绘制上下文 this.backBufferContext2D.clearRect(0, 0, this.backBuffer.width, this.backBuffer.height); this.context2D.clearRect(0, 0, this.canvas.width, this.canvas.height); // 首先更新所有游戏对象 for (x in this.gameObjects) { if (this.gameObjects[x].update) { this.gameObjects[x].update(dt, this.backBufferContext2D, this.xScroll, this.yScroll); } } // 然后绘制所有游戏对象 for (x in this.gameObjects) { if (this.gameObjects[x].draw) { this.gameObjects[x].draw(dt, this.backBufferContext2D, this.xScroll, this.yScroll); } } // 将后台缓冲复制到当前显示的画布 this.context2D.drawImage(this.backBuffer, 0, 0); };
这个draw函数就是所有渲染循环的核心。在前面的例子中,渲染循环的函数会直接修改要绘制到屏幕上的对象(笑脸)。如果只需绘制一个对象,这样做没有问题。但是,一个游戏要由几十个单独的对象组成,所以这个draw函数并没有直接在渲染循环中直接处理要绘制的对象,而是维护了一个保存着这些对象的数组,让这些对象自己来更新和绘制自己。
首先,计算自上一帧渲染所经过的时间。即便我们在代码里写了每秒钟调用30次draw函数,但谁也无法保证事实如此。通过计算自上一帧渲染所经过的时间,可以做到尽可能让游戏的执行与帧速率无关。
// 计算从上一帧到现在的时间 var thisFrame = new Date().getTime(); var dt = (thisFrame - this.lastFrame)/1000; this.lastFrame = thisFrame;接着清理绘制上下文。
// 清理绘制上下文 this.backBufferContext2D.clearRect(0, 0, this.backBuffer.width, this.backBuffer.height); this.context2D.clearRect(0, 0, this.canvas.width, this.canvas.height);然后,就是调用游戏对象(这些对象是由GameObject类定义的,下一篇文章将介绍该类)自己的更新(update)和绘制(draw)方法。注意,这两个方法是可选的(这也是我们在调用它们之前先检查它们是否存在的原因),但差不多每一个对象都需要更新和绘制自已。
// 首先更新所有游戏对象 for (x in this.gameObjects) { if (this.gameObjects[x].update) { this.gameObjects[x].update(dt, this.backBufferContext2D, this.xScroll, this.yScroll); } } // 然后绘制所有游戏对象 for (x in this.gameObjects) { if (this.gameObjects[x].draw) { this.gameObjects[x].draw(dt, this.backBufferContext2D, this.xScroll, this.yScroll); } }最后,把后台缓冲复制到前台缓冲,最终用户就可以看到下一帧了。
// 将后台缓冲复制到当前显示的画布 this.context2D.drawImage(this.backBuffer, 0, 0);理解了draw函数,下面再分别讲一讲addGameObject和removeGameObject函数。
/** 向gameObjects集合中添加一个GameObject @param gameObject The object to add */ this.addGameObject = function(gameObject) { this.gameObjects.push(gameObject); this.gameObjects.sort(function(a,b){return a.zOrder - b.zOrder;}) }; /** 从gameObjects集合中删除一个GameObject @param gameObject The object to remove */ this.removeGameObject = function(gameObject) { this.gameObjects.removeObject(gameObject); }
利用addGameObject和removeGameObject(在Utils.js文件里通过扩展Array.prototype添加)函数,可以在GameObjectManager所维护的GameObject集合(即gameObjects变量)中添加和删除游戏对象。
GameObjectManager类是我们这个游戏框架中最复杂的一个类。在下一篇文章中,我们会讲解游戏框架的另外几个类:GameObject、VisualGameObject、Bounce和ApplicationManager。
好了,现在放松一下,看一看Demo吧。
在这篇文章里,我们继续介绍构成这个基本JavaScript游戏框架的其他必要的类。
前一篇文章介绍了GameObjectManager类,该类负责画布的渲染并允许GameObject类更新和删除它们自己。下面就来看一看GameObject类。
/** 游戏中出现的所有元素的基类 @class */ function GameObject() { /** 显示的深度次序。较小的zOrder值表示先渲染,因而会在背景中。 @type Number */ this.zOrder = 0; /** x轴的坐标 @type Number */ this.x = 0; /** y轴的坐标 @type Number */ this.y = 0; /** 初始化游戏对象,并将其添加到GameObjectManager维护的对象列表中 @param x x轴的坐标 @param y y轴的坐标 @param z 元素的z次序(背景元素的z值较小) */ this.startupGameObject = function(/**Number*/ x, /**Number*/ y, /**Number*/ z) { this.zOrder = z; this.x = x; this.y = y; g_GameObjectManager.addGameObject(this); return this; } /** 清理当前对象,将其从GameObjectManager维护的对象列表中删除 */ this.shutdownGameObject = function() { g_GameObjectManager.removeGameObject(this); } }
这个GameObject类(是一个引擎类)的目的,是为游戏中将会出现的所有对象定义一些共有的属性,包括它们的位置(x和y)和深度(z)。需要注意的是,我们不会直接创建GameObject类的实例,而是会再创建一个类来扩展它。
这个类的x和y坐标值没有什么好说的——就是相应对象左上角位置在画布上的坐标。关键是GameObject中的z值,这个值定义的是对象的深度。理解这个值很重要,这个值较小的GameObject会先绘制到画布上。换句话说,z值较大的GameObject将被绘制到z值较小的GameObject上面。
上一篇文章里介绍过,所有类都是通过一个类似startupClassName的函数完成自身初始化的。因此,GameObject类就有一个名为startupGameObject的函数。在这个函数里,除了初始化所有变量外,还会通过addGameObject函数把当前的GameObject添加到由GameObjectManager维护的GameObject列表中。
/** 初始化游戏对象,并将其添加到GameObjectManager维护的对象列表中 @param x x轴的坐标 @param y y轴的坐标 @param z 元素的z次序(背景元素的z值较小) */ this.startupGameObject = function(/**Number*/ x, /**Number*/ y, /**Number*/ z) { this.zOrder = z; this.x = x; this.y = y; g_GameObjectManager.addGameObject(this); return this; }函数shutdownGameObject用于清除GameObject。这里所谓的清除,是指GameObject通过removeGameObject函数将自身从GameObjectManager中删除。
/** 清理当前对象,将其从GameObjectManager维护的对象列表中删除 */ this.shutdownGameObject = function() { g_GameObjectManager.removeGameObject(this); }
/** 出现在游戏中的所有元素的基类 @class */ function VisualGameObject() { /** 由当前对象显示的图像 @type Image */ this.image = null; /** 将当前元素绘制到后台缓冲 @param dt 自上一帧绘制起经过的秒数 */ this.draw = function(/**Number*/ dt, /**CanvasRenderingContext2D*/ context, /**Number*/ xScroll, /**Number*/ yScroll) { context.drawImage(this.image, this.x - xScroll, this.y - yScroll); } /** 初始化当前对象 @param image 要显示的图像 */ this.startupVisualGameObject = function(/**Image*/ image, /**Number*/ x, /**Number*/ y, /**Number*/ z) { this.startupGameObject(x, y, z); this.image = image; return this; } /** 清理当前对象 */ this.shutdownVisualGameObject = function() { this.shutdownGameObject(); } } VisualGameObject.prototype = new GameObject;VisualGameObject也是一个引擎类,它扩展了GameObject类,为将在屏幕上绘制的对象定义了更具体的属性和函数。顾名思义,可见对象显然是需要绘制的对象,因此VisualGameObject定义了一个image属性,当把当前对象绘制到后台缓冲时,将以这个属性作为图形的来源。
/** 由当前对象显示的图像 @type Image */ this.image = null;此外,还需要写几行代码,以便把这个对象实际地绘制到后台缓冲——这就是draw函数了,它接受图像并基于GameObject类中定义的x和y值将其复制到后台缓冲。
/** 将当前元素绘制到后台缓冲 @param dt 自上一帧绘制起经过的秒数 */ this.draw = function(/**Number*/ dt, /**CanvasRenderingContext2D*/ context, /**Number*/ xScroll, /**Number*/ yScroll) { context.drawImage(this.image, this.x - xScroll, this.y - yScroll); }
/** ApplicationManager用于管理应用 @class */ function ApplicationManager() { /** 初始化对象 @return 对初始化对象的引用 */ this.startupApplicationManager = function() { this.bounce = new Bounce().startupBounce(g_image); return this; } }
ApplicationManager是第一个应用类,之所以将其归为应用类,是因为它用来定义应用的运行方式,而不是定义与浏览器的底层交互。这个类非常简单,只用来创建并初始化Bounce类的一个新实例。表面上看,创建一个类仅仅是为了创建一个对象有点多此一举。但在更复杂的应用中,把创建和管理游戏对象的逻辑放到一起是很有必要的。
/** 测试类,用于演示VisualGameObject类的用法 @class */ function Bounce() { /** x轴的运动方向 @type Number */ this.xDirection = 1; /** y轴的运动方向 @type Number */ this.yDirection = 1; /** 运动速度 @type Number */ this.speed = 10; /** 初始化对象 @return 对初始化对象的引用 */ this.startupBounce = function(image) { this.startupVisualGameObject(image, 0, 0, 0); return this; } /** 更新对象 @param dt 自上一帧绘制起经过的秒数 @param context 绘制上下文 @param xScroll x轴的全局滚动值 @param yScroll y轴的全局滚动值 */ this.update = function (/**Number*/ dt, /**CanvasRenderingContext2D*/context, /**Number*/ xScroll, /**Number*/ yScroll) { this.x += dt * this.speed * this.xDirection; this.y += dt * this.speed * this.yDirection; if (this.x >= 450) { this.x = 450; this.xDirection = -1; } else if (this.x <= 0) { this.x = 0; this.xDirection = 1; } if (this.y >= 250) { this.y = 250; this.yDirection = -1; } else if (this.y <= 0) { this.y = 0; this.yDirection = 1; } } } Bounce.prototype = new VisualGameObject;
Bounce是第二个应用类,它扩展了VisualGameObject类,并将把自己绘制到屏幕上。Bounce类会显示一幅在屏幕上反弹的图像,效果非常类似第一篇文章中举的例子。这个类是在前面所有类的基础上实现最终动画的关键。
startupBounce函数接受一幅图像,通过调用startupVisualGameObject来初始化这个基本的类。
/** 初始化对象 @return 对初始化对象的引用 */ this.startupBounce = function(image) { this.startupVisualGameObject(image, 0, 0, 0); return this; }而update函数(将被GameObjectManager在渲染期间调用)会更新图像的位置,在图像到达画布边缘时反转方向。
/** 更新对象 @param dt 自上一帧绘制起经过的秒数 @param context 绘制上下文 @param xScroll x轴的全局滚动值 @param yScroll y轴的全局滚动值 */ this.update = function (/**Number*/ dt, /**CanvasRenderingContext2D*/context, /**Number*/ xScroll, /**Number*/ yScroll) { this.x += dt * this.speed * this.xDirection; this.y += dt * this.speed * this.yDirection; if (this.x >= 450) { this.x = 450; this.xDirection = -1; } else if (this.x <= 0) { this.x = 0; this.xDirection = 1; } if (this.y >= 250) { this.y = 250; this.yDirection = -1; } else if (this.y <= 0) { this.y = 0; this.yDirection = 1; } } }
就这些了。你可能会想,怎么没有与绘制这个对象有关的代码呢?相应的代码都在VisualGameObject类的draw函数中了。而且,由于VisualGameObject类扩展了GameObject类,所以我们知道每渲染一帧都会调用一次update和draw函数。Bounce类中的所有代码只跟让图像反弹有关,也就是修改变量x和y。
好啦,我们已经创建了一批类,基于这些类也实现了与第一个示例相同的效果。而有了这个框架,再创建游戏就不必因为绘制画布等底层逻辑以及管理游戏对象等问题而重复编码了。
看看示例Demo吧。http://webdemos.sourceforge.net/jsplatformer3/jsplatformer3.html
http://www.brighthub.com/internet/web-development/articles/40511.aspx
视差滚动是在2D应用中创造立体纵深感的一种技术。这篇文章就来看一看在我们刚刚创建的游戏框架基础上实现视差滚动有多容易。
有了游戏框架,就可以通过画布元素来做一些好玩的东西了。
视差滚动指的是屏幕上的几个图层发生相对位移的效果,换句话说,背景图层滚动得比它前面的那些图层要慢一些。这种创造视觉纵深感的技术在2D游戏中的应用极为普遍。
/** 这个类可以重复显示纹理图像,支持纹理图像在x轴或y轴偏移 @class */ function RepeatingGameObject() { /** 最终图像占据的宽度 @type Number */ this.width = 0; /** 最终图像占据的高度 @type Number */ this.height = 0; /** 绘制时应用多少scrollX和scrollY @type Number */ this.scrollFactor = 1; /** 初始化对象 @return 对初始化对象的引用 */ this.startupRepeatingGameObject = function(image, x, y, z, width, height, scrollFactor) { this.startupVisualGameObject(image, x, y, z); this.width = width; this.height = height; this.scrollFactor = scrollFactor; return this; } /** 清理对象 */ this.shutdownstartupRepeatingGameObject = function() { this.shutdownVisualGameObject(); } /** 把当前元素绘制到后台缓冲 @param dt 自上一帧绘制起经过的秒数 @param context 绘制上下文 @param xScroll x轴的全局滚动值 @param yScroll y轴的全局滚动值 */ this.draw = function(dt, canvas, xScroll, yScroll) { var areaDrawn = [0, 0]; for (var y = 0; y < this.height; y += areaDrawn[1]) { for (var x = 0; x < this.width; x += areaDrawn[0]) { // 绘制下一张贴片左上角的点 var newPosition = [this.x + x, this.y + y]; // 剩余的绘制空间 var newFillArea = [this.width - x, this.height - y]; // 第一次必须从图像的中央开始绘制 // 后续贴片从上方或左侧绘制 var newScrollPosition = [0, 0]; if (x==0) newScrollPosition[0] = xScroll * this.scrollFactor; if (y==0) newScrollPosition[1] = yScroll * this.scrollFactor; areaDrawn = this.drawRepeat(canvas, newPosition, newFillArea, newScrollPosition); } } } this.drawRepeat = function(canvas, newPosition, newFillArea, newScrollPosition) { // 找到重复绘制纹理图像的起点(左上角) var xOffset = Math.abs(newScrollPosition[0]) % this.image.width; var yOffset = Math.abs(newScrollPosition[1]) % this.image.height; var left = newScrollPosition[0]<0?this.image.width-xOffset:xOffset; var top = newScrollPosition[1]<0?this.image.height-yOffset:yOffset; var width = newFillArea[0] < this.image.width-left?newFillArea[0]:this.image.width-left; var height = newFillArea[1] < this.image.height-top?newFillArea[1]:this.image.height-top; // 绘制图像 canvas.drawImage(this.image, left, top, width, height, newPosition[0], newPosition[1], width, height); return [width, height]; } } RepeatingGameObject.prototype = new VisualGameObject();
这个RepeatingGameObject类可以让图像在一个确定的区域内重复和滚动。此前,我们已经实现了绘制整幅图像。而RepeatingGameObject的不同之处在于,它拿到一幅图像并用它来填充一块范围既定的区域(其尺寸与绘制的图像无关)。我们通过这个类每次显示一幅大图像(如一座山的全景图)的一小部分,从而创建出一个背景。
也许你已经注意到了GameObjectManager的xScroll和yScroll属性,它们被传递给了GameObject的draw和updata函数。这两个值定义的是摄像机沿x轴或y轴移动了多远。RepeatingGameObject使用这两个值来它们显示的纹理,以创造移动的假象。
首先,定义RepeatingGameObject要绘制的区域。底层的GameObject类的x和y属性定义了左上角位置,而新的width和height属性定义的是绘制区域。
/** 最终图像占据的宽度 @type Number */ this.width = 0; /** 最终图像占据的高度 @type Number */ this.height = 0; /** 绘制时应用多少scrollX和scrollY @type Number */而scrollFactor属性用于改变RepeatingGameObject滚动的量,该变化是通过传递到draw函数的xScroll和yScroll来控制的。将scrollFactor设置为小于1的值,会导致滚动变慢,从而造就画面中的远景。
/** 绘制时应用多少scrollX和scrollY @type Number */ this.scrollFactor = 1;最后两个draw和drawRepeat函数具体负责渲染贴片及偏移的纹理。
/** 把当前元素绘制到后台缓冲 @param dt 自上一帧绘制起经过的秒数 @param context 绘制上下文 @param xScroll x轴的全局滚动值 @param yScroll y轴的全局滚动值 */ this.draw = function(dt, canvas, xScroll, yScroll) { var areaDrawn = [0, 0]; for (var y = 0; y < this.height; y += areaDrawn[1]) { for (var x = 0; x < this.width; x += areaDrawn[0]) { // 绘制下一张贴片左上角的点 var newPosition = [this.x + x, this.y + y]; // 剩余的绘制空间 var newFillArea = [this.width - x, this.height - y]; // 第一次必须从图像的中央开始绘制 // 后续贴片从上方或左侧绘制 var newScrollPosition = [0, 0]; if (x==0) newScrollPosition[0] = xScroll * this.scrollFactor; if (y==0) newScrollPosition[1] = yScroll * this.scrollFactor; areaDrawn = this.drawRepeat(canvas, newPosition, newFillArea, newScrollPosition); } } } this.drawRepeat = function(canvas, newPosition, newFillArea, newScrollPosition) { // 找到重复绘制纹理图像的起点(左上角) var xOffset = Math.abs(newScrollPosition[0]) % this.image.width; var yOffset = Math.abs(newScrollPosition[1]) % this.image.height; var left = newScrollPosition[0]<0?this.image.width-xOffset:xOffset; var top = newScrollPosition[1]<0?this.image.height-yOffset:yOffset; var width = newFillArea[0] < this.image.width-left?newFillArea[0]:this.image.width-left; var height = newFillArea[1] < this.image.height-top?newFillArea[1]:this.image.height-top; // 绘制图像 canvas.drawImage(this.image, left, top, width, height, newPosition[0], newPosition[1], width, height); return [width, height]; }
/** ApplicationManager用于管理应用 @class */ function ApplicationManager() { /** 初始化对象 @return A 对初始化对象的引用 */ this.startupApplicationManager = function() { this.startupGameObject(); this.background3 = new RepeatingGameObject().startupRepeatingGameObject(g_back2, 0, 100, 3, 600, 320, 1); this.background2 = new RepeatingGameObject().startupRepeatingGameObject(g_back1, 0, 100, 2, 600, 320, 0.75); this.background = new RepeatingGameObject().startupRepeatingGameObject(g_back0, 0, 0, 1, 600, 320, 0.5); return this; } /** 更新当前对象 @param dt 自上一帧绘制起经过的秒数 @param context 绘制上下文 @param xScroll x轴的全局滚动值 @param yScroll y轴的全局滚动值 */ this.update = function(/**Number*/ dt, /**CanvasRenderingContext2D*/ context, /**Number*/ xScroll, /**Number*/ yScroll) { g_GameObjectManager.xScroll += 50 * dt; } } ApplicationManager.prototype = new GameObject
在这里,我们通过ApplicationManager创建了三个RepeatingGameObject类的实例,每个实例分别显示为一个图层,使用z(深度)和scrollFactor值来创造RepeatingGameObject 渐远和渐慢的效果。
最终结果很不错。视差滚动为画布赋予了完美的纵深感,而整个效果只多编写了一个类就实现了。
看一看视差滚动的Demo吧。http://webdemos.sourceforge.net/jsplatformer4/jsplatformer4.html