HTML5游戏编程核心技术与实战

1. 第1章 游戏和HTML5初探

2. 第2章 Canvas绘图

3. 第3章 多媒体元素

4. 第4章 制作2D游戏引擎

5. 第5章 网络通信基础

6. 第6章 游戏常用算法

7. 第7章 Box2D引擎和物理游戏

8. 第8章 使用CSS3开发游戏

9. 第9章 编程实战之太空战机

10. 第10章 编程实战之《你画我猜》

11. 第11章 编程实战之《疯狂的小鸟》

12. 附录A 游戏的历史

13. 附录B 游戏类型

14. 版权

第1章 游戏和HTML5初探

从时间上来计算，游戏行业从诞生到现在还不到100年历史，跟其他传统的行业相比，它甚至就像襁褓中的婴儿一样小，但正是这婴儿，正逐渐挑战着众多的传统行业。现在，很多人都会在不同的时刻玩着不同的游戏，也许你正在虚拟的网络游戏中热血澎湃地战斗，也许你正在电子游戏竞技中展现你的人生价值，也许你在忙碌的工作后，玩着切水果游戏不停地发泄，总之，你会感受到，它正在悄然融入到我们的生活当中，正在成为你生活的一部分。

随着新一代Web开发标准——HTML5诞生，各大浏览器厂商和软件厂商都不遗余力地支持 HTML5 标准，加入到 HTML5 的阵营，互联网时代的新一轮革命即将展开，当游戏碰上HTML5将会产生怎样的激烈火花，真是让人感到期待。

1.1 网页游戏和HTML5

1.1.1 网页游戏简介

从电子游戏载体来说，电子游戏现在基本上分成了3个主要的阵营：第一部分是以电视游戏为主，第二部分以个人电脑游戏为主，第三部分是以手机和平板为主。而从游戏的玩家数量来说，游戏经历了从单机游戏时代到现在的网络游戏时代。随着互联网的普及，以及电脑硬件的飞速发展，互联网游戏正处于高速发展的时期，特别是网页游戏得到了空前的发展。

网页游戏也称Web Game，是一种无端网游，和《魔兽》系列、《星际》系列等传统的网络游戏相比，网页游戏不需要下载客户端，玩家只需要通过电脑打开浏览器即可进行游戏，与传统的大型网游比起来，其优点是无需安装、占据空间小、使用方便等，对于开发人员来说，比开发传统的网络游戏更容易。

网页游戏从最早的 MUD（Multiple User Domain，多用户虚拟空间游戏，玩家爱称"泥巴游戏"）发展而来，早期的 MUD 游戏限于技术条件，几乎是纯文字网游，没有图形，全部用文字和字符画来构成。根据维基百科记载，世界上第一款真正意义上的实时多人交互网络 MUD 游戏"MUD1"，是在 1978 年，由英国埃塞克斯大学的罗伊·特鲁布肖用 DEC-10编写的。随着Internet的和HTML语言的飞速发展，纯文字类的游戏淡出历史舞台，丰富多彩的带图像的WebGame逐渐兴起。现在的一些2D网页游戏几乎能和传统的网络游戏媲美，比如《可乐吧》、《弹弹堂》、《第九城市》、4399 游戏平台、91wan 游戏平台、1wan 游戏平台等。

由于网页游戏运行的环境在浏览器中，所以常见的开发语言，在客户端主要使用HTML、CSS样式以及JavaScript语言，服务器可以使用诸如C/C++、C#、Java、PHP等传统的服务器端语言。在HTML4时代，HTML语言受到诸如缺乏高效的图像渲染方法、缺乏实时的网络通信方法等技术支持，加上JavaScript运行效率相对一些常用的游戏编程语言 C/C++、Java 低，所以目前比较成熟的网页技术都需要在浏览器中安装一些特殊的插件（Flash Player、Applet、ActiveX、Unity Web Player等）以帮助Web Game 能够高效地运行。就目前来说，Web Game 使用的最广泛的客户端技术主要还是以Flash平台为主，从1995年到现在经过了近20多年的时间，各种关于动画、游戏方面的技术已经非常成熟，所以 Flash 通常作为 Web Game 首选的开发平台。但随着HTML新标准的发布，也就是HTML5的横空出世，也就注定了Flash的路将不会长久。Flash的研发公司Adobe已经于2011年宣布停止Flash后续研发工作，而转向新的HTML5。

1.1.2 HTML5 简介

自从HTML5新标准发布以来，就引起了互联网技术的新一轮风暴，作为新一代的Web技术的领航者，它受到了各大厂商的追捧，几乎所有的IT大厂商都全力提供对HTML5规范的支持。相对于 HTML4.X 版本而言，HTML5 提供了许多令人激动的新特性，这些新的特性将为HTML5开创新的Web时代提供了坚强的基石。

HTML（Hypertext Markup Language，超文本标记语言）是专门在Internet上传输多媒体的一种语言，正是有了HTML语言的出现，现在的互联网世界才显得丰富多彩，从1993年第一个版本的HTML语言诞生以来，共经历了以下几个重要的发布版本。

（1）HTML（第一版），这是一个非正式的工作版本，于 1993 年 6 月作为 IETF（Internet Engineering Task Force，IETF）草案发布。

（2）HTML2.0，1995年11月作为RFC1866（Request For Comments）发布，RFC是由IETF发布的备忘录。

（3）HTML3.2，1997年1月14日，成为W3C（World Wide Web Consortium，W3C）推荐标准。

（4）HTML4.0，1997年12月18日，W3C推荐标准。

（5）HTML4.01，1999年12月24日，W3C推荐标准。

（6）Web Applications1.0，2004 年作为HTML5 草案的前身由WHATWG（Web Hypertext Application Technology Working Group，以推动HTML5 标准而建立的组织）提出，2007 年被W3C组织批准。

（7）HTML5草案，2008年1月22日，第一份草案正式发布。

（8）HTML5.1，2012年12月17日，W3C的首份规范草案发布。

事实上到现在为止，HTML5还处于发展和完善时期，但诸多HTML5中新增加的功能已经让各大软件厂商鼎立支持。从HTML5 前身的名称（Web Application）我们可以看出HTML5 的决心，HTML不再只是单纯的网站制作语言，而是作为Web应用程序的开发语言应运而生，为了能够承担Web应用程序所能够完成的功能，在不需要安装任何插件的情况下，HTML5中提供了以下激动人心的功能。

（1）canvas画布元素，canvas元素的诞生为HTML5能够支持较高性能的动画和游戏提供了条件。canvas可以直接使用硬件加速完成像素级别的图像渲染，不仅可以完成2D图形渲染，使用WegGL以及Shader语言还可以完成较高性能的3D图形渲染。

（2）多媒体元素，HTML5中提供了专门的audio元素和video元素，用于播放网络音频文件和视频文件，有了这两个多媒体元素，将不再需要单独安装插件就可以进行影音的播放，减少浏览器的污染程度。

（3）地理信息服务，通过HTML5的地理信息服务API我们可以获取到客户端所在的经度和纬度，利用这些信息可以向这个坐标附近的区域提供服务，可应用于地理交通信息查询、基于LBS（Location Based Services）的社交游戏等。

（4）本地存储服务，相对于传统的Cookie微量的本地存储技术，HTML5推出了新的本地存储规范，提供了容量更大，更安全和更易于使用的本地存储方案。

（5）WebSocket通信，弥补了传统的Web应用程序缺乏实时通信的功能，使用WebSocket技术可以在Web应用程序中实现类似于传统的C/S结构应用程序的通信功能，使得在Web环境中构建实时的通信程序提供了可能。

（6）离线存储，HTML5 的离线缓存应用的功能，使客户端即使没有连接到互联网络，也可以在客户端正常使用本地功能。有了这个强大的功能，用户可以更加灵活地控制缓存资源的加载，可以在没有网络信号的情况下使用本地应用。

（7）多线程，HTML5中提供了真正意义上的多线程解决方案，在HTML4的使用过程中，如果遇到客户端需要在后台执行耗时方法，则页面会处于"假死"状态，而在 HTML5 中可以使用多线程解决类似问题。

（8）设备，为了能够适应多种平台（PC、手机、平板），HTML5 提供了 Device 元素，可以让应用程序访问诸如摄象头、麦克风等硬件设备。

总之，这些新增加的特性无疑都是冲着本地应用程序而来，尽管HTML5还处于发展阶段，但已经成为下一代Web开发的标准。

1.1.3 HTML5 浏览器工具

原则上，HTML5应用程序可以在目前任何主流的浏览器（IE、Chrome、Opera、FireFox、Safari等）中执行，但由于HTML5规范目前正处于发展阶段，所以，现今的各种主流浏览器对HTML5规范支持的程度不太一样。根据国际权威的HTML5测试网站http://html5test.com/发布最新一期测试数据来看，目前，对HTML5 支持度最高的浏览器是Maxthon 4.0 和Chrome 26。这里所指的支持度是通过对HTML5的各项新特性诸如canvas、video/audio、新的form元素、WebGL等综合的一个兼容性支持，图1-1显示了各个主流版本浏览器在PC上对HTML5的支持程度。

图1-1 html5test.com对各浏览器的评分

从兼容性来看，目前Maxthon4.0（傲游）对HTML5的各项支持度最高，但从实际Web页面运行效果来看，综合JavaScript运行速度、DOM渲染速度、动画渲染速度、安全性、综合性能等因素，Chrome是目前较好的选择。同时，由于JavaScript是唯一的Web应用程序的客户端语言（使用带插件的AS和ActiveX等除外），而Chrome拥有最快的JavaScript执行引擎V8，这样就大大提高了Web应用程序客户端的运行效率。所以，从游戏开发的角度来看，目前Chrome无疑也是较好的选择，本书中的所有案例都在Chrome中测试通过。

html5test.com网站主要是针对 HTML5兼容性测试，另外一个对 HTML5综合性能检测权威的网站 http://peacekeeper.futuremark.com 可以针对浏览器进行全方位的测试，从检测结果来看，目前综合性能最高的也是Chrome浏览器，图1-2显示了peacekeeper的检测结果。

在 peacekeeper 首页以漫画的形式展示了目前浏览器的综合评论，打出的标语是"Google Chrome King of the Hill since 2011-09-19 Windows PC"，当然，也可以单击"Test your browser GO"来检测本地浏览器的综合性能，图 1-3 是笔者机器的检测分数（为准确测试，最好只打开一个浏览器Tab页，关闭其他耗时的进程）。

图1-2 peacekeeper的HTML5 检测首页

图1-3 peacekeeper在各大平台上的HTML5检测分数

1.1.4 HTML5 开发工具

原则上来说，使用任何文本编辑工具都可以完成HTML5代码的编写工作，编辑好HTML5代码后保存为.htm或者.html的文件即可，然后可以使用支持HTML5的浏览器查看效果。

工欲善其事，必先利其器。尽管可以直接使用NotePad编写HTML5应用程序，但为了能够提高代码的编写效率和减少出错概率，我们可以使用一些比较常用的IDE工具完成相关程序开发，这里提供几种轻量级和重量级的IDE工具。

1．轻量级开发工具

• EditPlus

EditPlus是一个轻量级的、可替代NotePad的文本编辑工具，最新的3.51的版本执行文件只有2 MB大小。麻雀虽小，五脏俱全，EditPlus功能强大，界面简洁美观，启动速度快；中文支持比较好；支持语法高亮；支持代码折叠；支持代码自动完成（但其功能比较弱），不支持代码提示功能；配置功能强大，且比较容易，扩展也比较强，默认支持HTML、CSS、PHP、ASP、Perl、C/C++、Java、JavaScript和VBScript等语法高亮显示，通过定制语法文件，可以扩展到其他程序语言。EditPlus内置了与Internet的无缝连接，可以在EditPlus的工作区域中直接打开Internet浏览窗口。本书的所有示例代码都是使用该软件编写，图1-4是EditPlus工具的界面。

图1-4 EditPlus开发界面

和EditPlus类似的轻量级的工具还有emEdit、UltraEdit-32等。

可以在官网http://www.editplus.com/下载最新版本。

• 1st JavaScript Editor Pro

从名字上可看出，该软件号称第一的 JavaScript 编辑工具，因为 Web 应用程序主要依靠HTML、CSS以及JavaScript语言，所以JavaScript的重要性可见一斑。由于之前介绍的文本编辑工具虽然支持各种语言的高亮语法显示，但都缺乏语言智能感知功能，对于编写较大型的程序而言不太方便。而本款软件则专门针对 JavaScript 提供了开发、校验和调试功能，除了针对JavaScript有优化外，也可针对其他语言（JavaScript、HTML、CSS、VBScript、PHP、ASP.NET）提供语法加亮显示，也提供了完整的HTML标记、属性、事件以及JavaScript事件、对象、函数等。目前最新的版本是 5.1，完整的安装包只有 7 MB 多，也是一款不可多得的轻量级的JavaScript编辑器。图1-5是JavaScript Editor Pro 工具的界面。

图1-5 1st JavaScript Editor Pro开发界面

类似的JavaScript编辑工具还有 JavaScript Menu Master、JavaScript Editor 等。

可以在官网http://yaldex.com下载最新版本。

2．重量级开发工具

轻量级工具的特点是免费、体积小、执行速度快，缺点是缺乏完善的编辑环境、完整的项目生命周期管理、版本管理、团队协作管理等，所以在个人制作小型程序上具有一定优势，但在完整的大型软件开发过程中，商业化的重量级的IDE才是首选。以下介绍几款常用的重量级开发工具。

• Eclipse

不用多说了，著名的跨平台开源集成开发环境，由于其优秀的灵活性、高扩展性，加上开源免费，成为了J2EE领域和Android领域开发工具首选的开发平台。Eclipse支持多个操作系统平台，如 Windows、Linux、iOS 等，最初的版本只是为了用于 Java 平台开发，但是在基于Eclipse强大的插件机制下，除了开发Java应用程序，Eclipse也提供了对C/C++（CDT）、PHP、Perl、Ruby、Python、telnet和数据库的开发，成为名副其实的多平台、多语言的程序开发集成环境。

图1-6是Eclipse Juno版本工具的界面，这是Eclipse标准版本的免费IDE工具，如果需要专门针对HTML5和Javascipt开发，可以直接使用基于Eclipse的开发工具Aptana。相对于标准版本的 Eclipse 集成环境，Aptana 几乎就是专为 Web2.0 定制的集成开发环境，提供了非常强大的JavaScript编辑和调试功能，支持最新的各种JavaScript框架（jQuery、Dojo、Prototype、Aflax等）的智能感知功能，除此之外，Aptana也提供了针对Rube、Perl、PHP以及Objective-C等语言的支持，可以通过官网http://www.eclipse.org/downloads/下载最新的Eclipse 标准IDE 工具，在官网http://www.aptana.com/可以下载到最新的Aptana集成环境工具。

• WebStorm

如果觉得Eclipse太过庞大，或者只想针对Web前端进行开发，WebStorm则是另一个非常不错的选择，完整的WebStorm 5.0 的安装版本只有90 MB左右。

图1-6 Eclipse Juno版开发界面

同上述的其他优秀的集成开发环境一样，WebStorm提供了对JavaScript和HTML的强有力的支持，号称"The smartest JavaScript IDE"，由此可见WebStorm的功能之强大，只是该集成平台只支持Web前端的开发，事实上WebForm是另一个优秀的Java开发平台IntelliJ的简化版。WebStorm开发界面如图1-7所示。

可以通过官网 http://www.jetbrains.com/webstorm/下载最新的 WebStorm 工具，需要注意的是这款优秀的JavaScript IDE是需要收费的。

图1-7 WebStorm开发界面

• Visual Studio

毫无疑问，Windows平台下最佳的开发工具就是Microsoft的Vistual Studio系列，从Visual Studio 10 SP1开始，VS系列提供了对HTML5以及JavaScript部分的智能感知功能。就Web前端开发来说（除开发ASP.NET程序外），相对于其他的Web开发功能稍弱。图1-8是Visual Studio 2008开发界面。

图1-8 Virtual Studio开发界面

同样地，我们可以从中文的MSDN官网http://msdn.microsoft.com/zh-cn/vstudio下载最新的Vistual Studio版本。

关于常用的HTML5开发工具就介绍到这里，相信大家会根据自己的需要而选择适当的工具进行开发。

1.2 HTML5新特性初探

准备好HTML5开发工具之后，我都迫不急待地想看看HTML5给我们带来了哪些让人激动不已的新特性，相信，你也会和我一样，对它感到兴奋。由于HTML5新增加了很多元素，这里，我们重点简单介绍一下在游戏中会使用到的一些元素，在介绍每一个新的元素时，我都会列举一些小的例子。现在假定读者有一定的HTML以及JavaScript方面的知识，如果您对这些代码完全不清楚，没关系，后面的章节中会详细地解说。目前，我们只需要知道HTML5能做什么就足够了。

1.2.1 canvas 元素

canvas是HTML5中一个非常重要的元素，canvas元素提供了硬件加速机制，能够让我们实现高效的绘图功能。第2章我们会详细介绍canvas功能，这里，我们先来领略一下canvas的魅力。

打开你的编辑工具，输入以下代码：

<!DOCTYPE html>

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<style type="text/css">

body, div{margin:0px;padding:0px;text-align:center}

#cav{

border:2px solid black;

border-radius:4px;

box-shadow:0px 0px 10px black;

-webkit-box-shadow:0px 0px 10px black;

-moz-box-shadow:0px 0px 10px black;

}

#bk{

margin:10px auto;

width:400px;

height:36px;

}

.bk{

text-align:center;

width:20px;

height:20px;

margin:12px;

display:inline-block;

border:1px dotted gray;

}

</style>

<body>

<div id="bk" align="center"></div>

<canvas id="cav" width="400" height="300"></canvas>

</body>

<script src="../js/jquery.js"></script>

<script charset="utf-8">

//定义画笔颜色

var bColor = ["#000000", "#999999", "#CC66FF", "#FF0000", "#FF9900", "#FFFF00",

"#008000", "#00CCFF"];

//当前画笔颜色

var col="#FF0000";

function initBrush()

{

for(var i=0;i<bColor.length;i++)

{

var bk=$("<span class='bk'></span>")

.css("background-color", bColor[i])

.click(function(){

col = $(this).css("background-color");

});

$("#bk").append(bk)

}

}

function initPainter()

{

//绑定绘图canvas

var can = $("#cav"), self = this, x=0, y=0;

var ctx = can[0].getContext("2d");

ctx.lineWidth = 2;

//绑定鼠标按下时间

can.on("mousedown", function(e){

e.preventDefault();

ctx.strokeStyle = col;

x = e.offsetX,

y = e.offsetY;

//开始路径

ctx.beginPath();

ctx.moveTo(x, y);

//绑定鼠标移动事件

can.on("mousemove", function(e){

var nx = event.offsetX,

ny = event.offsetY;

ctx.lineTo(x, y);

ctx.stroke();

x = nx;

y = ny;

});

//绑定鼠标抬起事件

can.on("mouseup", function(){

//取消鼠标移动事件

can.off("mousemove");

});

})

}

$(document).ready(function(){

initBrush();

initPainter();

})

</script>

</html>

虽然有点长，但当你完成它的时候，我想，一定不会让你失望的，这是一个不错的开始，也许现在你可能还看不太懂，没关系，第2章就会对canvas元素进行详细介绍。以上代码通过canvas元素完成了一个在浏览器中使用的画板，可以通过Chrome浏览器打开它，看到的效果如图1-9所示，你可以选择不同颜色的画笔在画板上进行涂鸦。

图1-9 canvas画板

1.2.2 WebGL

除了支持2D渲染技术外，canvas还提供了通过WebGL的方式，利用硬件加速提供3D渲染技术。以下代码使用了开源的three.js的3D引擎，完成了一个简单的旋转方块，注意，该3D效果完全使用WebGL技术，不需要安装任何插件。顺便说一下，three.js是一款完全免费使用的3D引擎，该引擎完全基于WebGL编写，事实上WebGL也是在canvas上进行渲染，目前对WebGL支持比较好的浏览器有Chrome、Firefox以及Safari等。

<!DOCTYPE html>

<html>

<head>

<meta charset="UTF-8">

<title>Cube</title>

<script src="../js/jquery.js"></script>

<!--引入Three.js-->

<script src="three.min.js"></script>

</head>

<style type="text/css">

body, div{margin:0px;padding:0px;text-align:center}

#sce{

border:2px solid black;

border-radius:4px;

width:480px;

height:320px;

margin:10px auto;

box-shadow:0px 0px 10px black;

-webkit-box-shadow:0px 0px 10px black;

-moz-box-shadow:0px 0px 10px black;

}

</style>

<body>

<div id="sce"></canvas>

<script>

$(document).ready(function(){

init();

})

//定义3D场景，摄像机光源等和立方体

var scene, cam, render, cube, light;

//初始化

function init()

{

initScene();

initCube();

animate();

}

function initScene()

{

scene=new THREE.Scene();

cam = new THREE.PerspectiveCamera(45, $("#sce").width()/$("#sce"). height(), 0.1, 2000);

cam.position.x= cam.position.y = cam.position.z=2;

cam.lookAt(new THREE.Vector3(0, 0, 0 ));

render = new THREE.WebGLRenderer({antialias:true});

render.setSize($("#sce").width(), $("#sce").height());

$("#sce").append(render.domElement);

}

function initCube()

{

//加载纹理

var text = THREE.ImageUtils.loadTexture( 'box.gif' );

text.anisotropy = render.getMaxAnisotropy();

//创建几何图形和材质

var g = new THREE.CubeGeometry(1, 1, 1),

m = new THREE.MeshBasicMaterial({ map: text });

cube = new THREE.Mesh(g, m);

scene.add(cube);

}

function animate()

{

requestAnimationFrame(animate);

cube.rotation.y += Math.PI/180;

render.render(scene, cam);

}

</script>

</body>

</html>

整个代码实现了一个绕Y轴旋转的方块，最好使用Chrome浏览器进行浏览，运行效果如图1-10所示。

图1-10 绕Y 轴旋转的木箱

当然，对于代码的细节暂时可以不用去关注，目前，我们只需要知道使用 WebGL 能做什么就足够了，对于目前的HTML5来说，2D的游戏将会是比3D更好的选择。

1.2.3 多媒体元素

正如我们所期待的一样，HTML5中增加了两个多媒体元素<audio>以及<video>，利用这两个多媒体元素，可以实现无插件的播放音频和视频对象。

比如，以下代码可以直接在浏览器中播放mp3音频文件。

<!DOCTYPE html>

<meta charset="utf-8" />

<style type="text/css">

body{text-align:center;}

</style>

<body>

<h2>简单的音视频</h2>

<audio id="a1" src="media/scg.mp3" autoplay mediagroup="gp" controls>

您的浏览器不支持audio元素

</audio><br>

</body>

</html>

在浏览器中可以看到一个带有简单控制面板的音频播放器，如图1-11所示。

图1-11 简单控制面板的音频播放器

1.2.4 地理信息服务

HTML5 提供了一组关于地理信息的API，通过这组API 我们可以获取到客户端所在的经度和纬度，利用这些信息可以向这个坐标附近的区域提供服务。这些服务一般指的是基于位置的服务（LBS），LBS能够广泛支持需要动态地理空间信息的应用，从寻找旅馆、急救服务到导航，从社交游戏到生活服务，几乎可以覆盖生活中的所有方面。常见的一些LBS的应用有：位置信息查询、急救服务、道路辅助与导航、社交游戏等。

借助HTML提供的Geolocation API 功能，很容易获取到用户当前的位置，结合这组API，我们可以编写基于LBS的社交类游戏。

同样地，我们给出一段简单的代码，可以获取用户所在的位置信息。

<!DOCTYPE html>

<html>

<head>

<title> Location </title>

<meta charset="utf-8" />

</head>

<body>

<input type="button" id="btnLocation" value="获取位置信息"/>

</body>

<script src="js/jquery.js"></script>

<script>

//检测是否支持地理信息

function isSupportGeo()

{

return (navigator.geolocation!=null)

}

//绑定click事件

$("#btnLocation").click(function(){

if(!isSupportGeo())

{

alert("浏览器不支持地理位置定位!");

}

else

{

navigator.geolocation.getCurrentPosition(doSucc, doFail);

}

});

//处理成功

function doSucc(position)

{

//alert("成功获取地理位置!");

alert("成功获取地理位置，您当前所处的位置位于经度:"+position.coords.longitude+"

纬度:"+position.coords.latitude);

}

//处理失败

function doFail(error)

{

var errors=["拒绝地理信息服务","获取不到地理信息","获取信息超时"]

alert(errors[error.code+1]);

}

</script>

</html>

代码绑定一个"获取位置信息"的按钮，单击按钮将会调用isSupportGeo方法检测浏览器是否支持地理信息，如果支持的话就调用getCurrentPosition获取当前设备的地理信息。需要注意的是，第一次访问地理信息的时候由于个人隐私的问题，浏览器会弹出一个窗口询问用户是否允许让浏览器访问自己的位置，用户可以选择接受或者拒绝，如果拒绝的话将会得到一个错误。

1.2.5 本地存储

我们知道在传统的Web应用程序开发过程中，当客户端和服务器进行数据交互时，远程服务器需要存储客户端的各种数据，如果需要在客户端保留数据，可以采用"Cookie"的形式，但Cookie 的存储量很小，通常只有4 kB，而且安全性和使用性都还有所欠缺。为了能够满足于HTML5本地应用的迅速发展，弥补HTML在本地数据持久化的弱势，HTML5推出了新的本地存储规范，提供了容量更大、更安全和更易于使用的本地存储方案。

HTML5 中使用 Web Storage 技术进行本地存储，能够在 Web 客户端进行数据存储。WebStorage 曾经属于 HTML5 的规范，目前已经被独立出来形成单独的规范体系。简单来说使用Web本地存储类似于HashMap一样，采用键值对的形式保存数据，而且保存的数据会根据需要持久化在浏览器所在的客户端，具体来说Web Storage又可以分为以下两种。

• localStorage。将数据保存在客户端本地的硬件设备（通常指硬盘，但也可以是其他硬件设备）中，即使浏览器被关闭了，该数据仍然存在，下次打开浏览器访问网站时仍然可以继续使用。

• sessionStorage。将数据保存在session 对象中。session 对象就是会话对象，session 中存储的数据独立于每个客户，该数据会随着浏览器的关闭而消失。

这两者的区别在于，sessionStorage 为临时保存，而 localStorage 为永久保存。localStorage提供了5 MB的存储空间，而sessiongStorage 甚至没有限制，取决于内存的大小。

对于这两种本地存储对象都可以使用以下方法进行数据存取。

• setItem (key, value)。使用键值对的方式保存数据，key为键，value为值，键是一个字符串，而数据则可以是任何类型的JavaScript基本数据类型，包括字符串、Boolean、整数和浮点数。需要注意的是，这些数据在存储时实际上是以字符串保存的。因此在访问数据时需要进行数据类型的转换，也可以直接用 localStorage.key = value 或者localStorage[key] = value 的方式设置，对于复杂的数据类型，可以使用JSON 格式的对象转换成字符串对象存储。

• getItem (key)。根据 key 键值来获取数据，也可以直接用 localStorage.key 或者localStorage[key]获取，需要注意的是，这些数据在取出来时实际上是字符串。因此在访问数据时需要进行数据类型的转换。

• removeItem (key)。清除key键值。

• clear()。清除所有的键值对。

例如，以下简单的代码用于记录用户访问页面的总次数：

<body>

<h2>您是第<span id="ct"></span>次访问本页面</h2>

</body>

<script>

$(document).ready(function(){

//获取访问次数

readCount();

})

//获取访问次数

function readCount()

{

var count=localStorage["count"];

if(count==null){count=1;}

else {count = Number(count)+1;}

$("#ct").text(count);

localStorage["count"] = count;

}

</script>

上面代码中记录了用户访问页面次数，第一次访问时，获取的count为空，设置其为1，随后的访问中获取数据后通过Number函数转成数字进行累加并重新设置到localStorage中。需要注意的是，即使关闭浏览器，下次打开时，数据依然存在，因为localStorage的数据实际上持久化在本地文件中。

1.2.6 Web sockets 网络通信

一直以来，HTTP 的无状态性和单向性作为常用的应用已经足够，但还无法实现游戏中需要的实时双向通信技术，而HTML5中新增加的WebSocket通信元素则弥补了这一缺陷，使用新的Web sockets 技术对于实现高效的Web 网络游戏提供了基础。

我们可以使用以下代码连接支持Web sockets 协议的服务器端，并发送数据。

var ws = new WebSocket("ws://localhost:9000/socket");

ws.onopen = function(){

try

{

ws.send("Hello, Web Socket");

}

catch(e)

{

console.log("发送消息失败!");

}

}

当然，需要注意的是，以上只是使用WebSocket元素的客户端代码，需要有支持WebSocket协议的服务器端支持，在本书的第5章会详细介绍使用WebSocket的客户端以及Node.js的服务器端通信技术。

1.2.7 Web Worker 多线程

Web Worker 技术填补了 JavaScript 中一直所缺少的多线程技术，之前的 HTML 中，进行DOM元素渲染以及执行JavaScript脚本都是在单一的线程中执行，一旦在JavaScript中需要执行比较耗时间的操作，则会阻塞当前线程的执行。所带来的效果就是，当前的浏览器处于无响应状态，直到浏览器会弹出一个提示脚本运行时间过长的信息。这里，我们看一个简单的例子。

<!DOCTYPE html>

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<style type="text/css">

body, div{margin:0px;padding:0px;text-align:center}

</style>

<body>

<input type="button" id="btn_calc" value="计算圆周率" />

<input id="txt_rt" />

</body>

<script src="../js/jquery.js"></script>

<script charset="utf-8">

$(document).ready(function(){

$("#btn_calc").on("click", function(){

$("#txt_rt").val(getPI(80000000));

})

})

//求圆周率

function getPI(n)

{

var a=0;

for (i=0;i<=n;i++)

{

a=a+4*Math.pow(-1, i)/(2*i+1);

}

return a;

}

</script>

</html>

在该例子中，提供了一个通过级数获取圆周率的函数，参数n表示迭代次数，次数越高，计算越精确。然后提供一个按钮计算圆周率在文本框中显示，在浏览器中运行，我们发现当迭代次数很高的时候，计算量较大，在得到计算结果之前，按钮和文本框都是处于无法响应的状态，这也符合实际情况，因为此时DOM元素的更新被大计算量的程序给阻塞了。如果使用Web Worker则不会出现此情况，我们可以把计算量大的工作放到一个单独的线程中运行，不会阻塞当前线程。

创建后台线程可以使用以下方式：

var worker = new Worker（"worker.js"）

我们把计算工作放到worker.js文件中，前台可以通过postmessage方法发送消息给Worker线程，请求Worker进行工作，直到Worker线程工作完毕返回结果。

对于前面计算PI的例子，我们可以修改如下，首先，创建worker.js文件，代码如下：

function getPI(n)

{

var a=0;

for (i=0;i<=n;i++)

{

a=a+4*Math.pow(-1, i)/(2*i+1);

}

return a;

}

onmessage = function(e)

{

var result=getPI(e.data);

postMessage(result);

}

worker.js文件中定义了计算PI的函数，我们需要定义一个onmessage函数，当前台通过调用worker对象的postmessage方法时，该回调函数被响应。

然后，把显示结果的页面修改如下：

<!DOCTYPE html>

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<style type="text/css">

body, div{margin:0px;padding:0px;text-align:center}

</style>

<body>

<input type="button" id="btn_calc" value="计算圆周率" />

<input id="txt_rt" />

</body>

<script src="../js/jquery.js"></script>

<script charset="utf-8">

$(document).ready(function(){

$("#btn_calc").on("click", function(){

var wk = new Worker("worker.js");

//注册返回结果，当woker发送postmessage时，该回调函数响应

wk.onmessage = function(e)

{

$("#txt_rt").val(e.data);

}

//发送计算请求

wk.postMessage(80000000);

})

})

</script>

</html>

这里，首先创建了一个Worker对象，然后在Workder对象中注册一个onmessage回调函数。注意，这个回调函数和 workder.js 文件中的 onmessage 是不同的，这里的回调函数当 woker.js文件中的onmessage函数执行完毕后响应，最后，调用Worker对象的postMessage方法发送计算请求，此时，woker.js文件中的onmessage函数就会执行。

修改成Worker版本后，你会发现，当单击按钮得到计算结果之前，按钮和文本框都是可以响应的，因为此时计算线程被独立出来了。

1.3 小结

本章简单介绍了一下网页游戏的现状、HTML5的开发工具和HTML5的一些新的特性，相信您对HTML5有了初步的认识。

从下章开始，我们就会一步一步接触到HTML5的游戏开发世界，准备好你的开发工具，拿起你的武器开始攻占HTML5游戏开发的高地。

第2章 Canvas绘图

首先，我们需要学习的是HTML5中的canvas元素，canvas元素是HTML5中一个非常重要的元素，也是实现游戏的核心，canvas元素的诞生为HTML5 能够支持较高性能的动画和游戏提供了可行性。HTML4 中，如果要完成类似画板和游戏的功能，需要使用 Flash，但 Flash需要在浏览器中安装Flash插件才能提供相应功能。本章围绕canvas元素讲解以下几个主要内容（对于非常熟悉本章内容的读者可略过本章）：

• 基本知识；

• 图形API；

• 图像API；

• 文本API；

• 坐标变换。

2.1 基本知识

在HTML4时代，不安装插件的情况下，基于浏览器的绘图组件是最初由微软向W3C递交的VML（Vector Markup Language 矢量标记语言）技术，但未被W3C采纳，只能在IE5.0及其后续版本中使用，后来VML 和PGML（由Adobe 和SUN 提出）合并成SVG（Scalable VectorGraphics，可伸缩矢量图形）规范。SVG技术是基于XML的矢量图形绘制技术，对于普通的图像应用来说足够，但是对于性能要求较高的游戏渲染来说，SVG 支持还不够，而且 SVG 也无法支持像素级别的操作，不能直接使用硬件加速机制，所以，需要有一个更适合游戏运行的高效的组件，这就是canvas组件。

在所有的桌面应用程序的开发平台中，几乎都有canvas组件，canvas组件几乎成了绘图组件的代名词。在基于浏览器的应用开发中，canvas元素最初是由苹果在自己的Mac OS X WebKit中推出，供应用程序和Safari浏览器使用。后来，W3C建议在下一代网络技术中加入该元素以适应新的技术要求，目前几乎所有的浏览器都提供了对canvas的支持。

canvas 元素本质上是在浏览器中提供了一块可绘制的区域，JavaScript 代码可以访问该区域，通过一套完整的API进行二维图像绘制。另外，如果显卡硬件支持3D图形功能，还可以使用canvas绘制3D图形。下面我们看看canvas的使用。

2.1.1 创建canvas

首先，我们来看如何在HTML5中使用canvas元素，使用canvas标签的代码如下：

<!DOCTYPE html>

<html>

<canvas id="can" width="800" height="600">Hello Canvas</canvas>

</html>

以上代码创建了一个id为can的canvas，canvas的宽度为800，高度为600。需要注意的是，创建的canvas在浏览器中看不到，因为它默认是透明的。另外，canvas比较特殊，我们最好不要用css样式定义宽度和高度，直接使用width和height定义就好，默认情况下canvas的width是300，height是150。需要注意的是，canvas的结束标签是必需的，当浏览器不支持canvas元素时，canvas标签中间的文字就会显示出来。

2.1.2 理解canvas 坐标系

加入了 canvas 元素之后，我们就可以通过 JavaScript，使用 canvas 元素提供的 API 操作canvas，在 canvas 上绘图。在绘图之前，我们需要了解 canvas 元素的坐标系，canvas的坐标系如图2-1所示。

图2-1 canvas坐标空间

可以看出，对于 canvas 来说，它的坐标系是以左上角为原点，向右延伸是 X 横坐标正方向，向下延伸是 Y 纵坐标正方向，所以原点的坐标是(x,y)=(0,0)。弄清楚坐标系对于以后使用canvas的API意义重大，在绘图的时候需要时刻记住坐标空间。

2.1.3 获取canvas 环境上下文

在定义好了canvas之后，我们就可以使用JavaScript访问canvas元素，使用canvas提供的一系列API。在使用canvas的时候，首先要得到canvas的环境上下文，有了canvas的环境上下文，才能够对canvas进行相应操作，可以通过以下方式获取环境上下文:

<script>

var context = canvasElement.getContext("2d");

</script>

使用canvas元素的getContext ("2d")的方式可以获取到2D图像操作的环境上下文，如果获取的 context 为空，表示该浏览器不支持 canvas。有了 context 变量后就可以在 canvasElement元素上进行 2 维图像操作，另外可以通过 getContext ("webgl")获取 webgl 环境上下文进行 3 维图像操作。

2.2 图形API

创建canvas和获取了canvas的环境上下文之后，就可以开始进行绘图了。绘图的方式有两类：一类是进行图形操作，另一类是图像操作。本小节主要涉及图形相关的API，要使用canvas的API进行绘图，通常需要进行下列步骤。

（1）获取canvas元素。通过document.getElementById()取得元素。

（2）获取canvas 元素的环境上下文。通过canvas.getContext ("2d")获取2D 图像上下文。

（3）确定绘图模式。使用canvas绘图有两种模式，一种是fill，另外一种是stroke。fill是填充的意思，使用该方式模式进行绘图时候会把颜色填充整个图形，而使用stroke的模式只会进行描边框。

（4）设定绘图样式。通过fillStyle和strokeStyle指定绘图样式，fillStyle和strokeStyle分别对应 fill 模式和 stroke 模式。绘图样式包括绘图的颜色及渐变方式，通常情况下默认的绘图样式颜色是#000000，也就是黑色。

（5）指定线宽。可以通过lineWidth设定绘制的线宽，默认值是1.0像素。

在进行绘制图形之前，需要先理解路径的概念。

2.2.1 理解路径

canvas中所有的图形可以看成一条路径，这条路径包含0个或者多个子路径。我们可以把路径看成当前canvas中所有图形的集合，而每一个图形就是一条子路径，一条子路径是由一系列点的集合组成。举个例子，我们在canvas中画了一个圆和一条直线，那么可以认为当前canvas中包含两条子路径，一条是圆，一条是直线，这两个图形都是由一个个点集组成，这个圆称为一个闭合的路径，而线是没有闭合的路径。很明显，所谓闭合就是整个图形是封闭的，图形的开始点和结束点相互连接。

事实上，为了提高绘制的效率，当使用 canvas 进行绘图的时候，所有的图形操作都只是往当前子路径上填加图形，并不是真正的调用绘图操作。比如使用 lineTo()进行画线操作，实际上它只是往当前子路径填加一条直线，最终调用stroke或者fill的时候，才是真正进行硬件操作进行绘图。

2.2.2 路径操作API

canvas中常见的创建和渲染路径的方法如表2-1所示。

表2-1 路径操作方法

续表

2.2.3 绘制线条

关于线条的绘制主要包含以下两个常用的方法。

• context.moveTo (x, y)：把画笔移动到(x, y)坐标，建立新的子路径。

• context.lineTo (x, y)：用于建立上一个点到(x, y)坐标的直线，如果没有上一个点，则等同于moveTo (x, y)，把(x ,y)点添加到子路径中。

最后使用stroke()可以对路径进行描边。

使用context.lineTo (x, y)绘制直线，代码如下：

<body>

<canvas id="can" ></canvas>

</body>

<script>

//获取2d上下文

var ctx = can.getContext("2d");

var width = can.width,

height = can.height;

ctx.moveTo(0, 0);

ctx.lineTo(width, height);

ctx.lineWidth=6;

ctx.strokeStyle = "red"

//开始画线

ctx.stroke();

</script>

代码首先获取can元素的环境上下文，获得can元素的宽度和高度，把画笔移动到原点处，然后使用lintTo (width, height)，建立一条从原点到右下角的直线，设定线的宽度为6 个像素，笔的颜色是红色，最后通过stroke对整个路径描边。

最后的效果如图2-2所示。

如果，我们需要绘制更复杂的图形，就需要根据一些点的集合，不断的使用lineTo方法，比如下面的代码就绘制了一个向右的箭头：

<body>

<canvas id="can" width="400" height="300" ></canvas>

</body>

<script>

//获取2d上下文

var ctx = can.getContext("2d");

var width = can.width,

height = can.height;

var pts=[[30, 100], [300, 100], [300, 50], [350, 130], [300, 210], [300, 160], [30, 160]]

ctx.strokeStyle="red";

ctx.lineWidth = 2;

ctx.moveTo(pts[0][0], pts[0][1]);

for(var i=1;i<pts.length;i++)

{

ctx.lineTo(pts[i][0], pts[i][1]);

}

ctx.closePath();

ctx.stroke();

</script>

最后的效果如图2-3所示。

图2-2 画线

图2-3 向右的箭头

这里需要注意的是，这里定义了7个点，在进行stroke之前使用closePath()函数闭合了路径，这时就会把最后一个点和第一个点连接起来，形成一个封闭的图形，当然，closePath并不是必需的。

2.2.4 绘制矩形

关于矩形的绘制主要包含以下两个常用的方法。

• rect (x, y, w, h)：建立两个子路径，一个是以点(x, y)为左上角，w和h 分别为宽度和高度的矩形，另一个是点(x, y)。这个方法只是建立路径，所以当进行绘制的时候还需要使用stroke()方法描边。

• 最直接的方法是使用 strokeRect (x, y, w, h)，该方法会以(x, y)为左上角，(x+width,y+height)为右下角绘制矩形。

• fillRect (x, y, w, h)方法则以(x, y)为左上角，(x+width, y+height)为右下角填充矩形。

• clearRect (x, y, w, h)则用来清除以(x, y)为左上角，(x+width,y+height)为右下角的矩形区域。这个方法在进行动画处理的时候非常有用，因为在连续绘制动态图形的时候，需要先清除画布上的一块区域。

对于图2-4所示的图形，代码如下：

图2-4 嵌套的矩形

<body>

<h2>画矩形例子</h2>

<canvas id="can" width="400" height="300" ></canvas>

</body>

<script>

//获取2d上下文

var ctx = can.getContext("2d");

var width = can.width,

height = can.height;

//计算最里面矩形左上角坐标，边长为10

var xOff = width*0.5+5, yOff = height*0.5+5;

for(var i=0;i<8;i++)

{

//以最里面矩形为中心，画同心矩形，边长增加20

ctx.strokeRect(xOff-10*i, yOff-10*i, i*20+10, i*20+10);

}

</script>

这段代码使用了strokeRect方法画出了8个同心的矩形。

2.2.5 绘制圆弧

关于圆弧的绘制主要包含以下两个常用的方法。

• arc (x, y, radius, startAngle, endAngle, anticlockwise)：arc 方法用来绘制一段圆弧路径，以(x, y)为圆心位置、radius为半径、startAngle 为起始弧度、endAngle 为终止弧度来画，而在画圆弧时的旋转方向则由最后一个参数 anticlockwise 来指定，如果为 true 就是逆时针，false则为顺时针，如果startAngle和endAngle分别为0和2*Math.PI，则就变成了绘制圆形。

• arcTo (x1, y1, x2, y2, radius)：这个函数实际上用来绘制同时和两条直线相切的，半径为radius 的最短圆弧，一条直线以上一个点和(x1, y1)构成，另一条直线以(x1, y1)和(x2, y2)构成。

这两个函数只是把圆弧添加到了路径中，如果绘制，则还需要通过stroke或者fill函数。使用arc方法绘制图2-5所示的8个同心圆的代码如下：

<body>

<h2>画圆形例子</h2>

<canvas id="can" width="400" height="300" ></canvas>

</body>

<script>

//获取2d上下文

var ctx = can.getContext("2d");

var width = can.width,

height = can.height;

//计算圆心

var xOff = width*0.5,

yOff = height*0.5;

for(var i=1;i<8;i++)

{

//以最里面矩形为中心，画同心圆，半径依次增加15

ctx.beginPath();

ctx.arc(xOff, yOff, i*15, 0, Math.PI*2, true);

ctx.closePath();

ctx.stroke();

}

</script>

需要注意的是，代码在 for 循环中，进行绘制圆形之前，使用了 beginPath()方法，beginPath()方法用于清除掉之前的路径。如果不清除的话，那么，每次绘制的时候都会把之前的路径又绘制，这样会降低绘制的效率。所以通常情况下，如果我们决定要绘制一个新的图形，最好先使用 beginPath()清除上一次的路径。

图2-5 同心圆

2.2.6 绘制贝塞尔曲线

关于贝塞尔曲线的绘制主要包含以下两个常用的方法。

• bezierCurveTo (cp1x, cp1y, cp2x, cp2y, x, y)：绘制一条三次贝塞尔曲线，这条曲线的开始点是子路径的最后一个点，结束点是(x, y)，而贝塞尔曲线的控制点是(cp1x, cp1y)和(cp2x, cp2y)。

• quadraticCurveTo (cpx, cpy, x, y)：绘制一条二次贝塞尔曲线，这条曲线的开始点是子路径的最后一个点，结束点是(x, y)，而贝塞尔曲线的控制点是(cpx, cpy)。

贝塞尔曲线是应用非常广泛的函数曲线，通常在计算机图形中用来为平滑曲线建立模型，图2-6分别显示了三次和二次的贝塞尔曲线，区别在于三次的贝塞尔曲线多了一个控制点。

图2-6 贝塞尔曲线

以下代码在canvas中显示了一个可以调节控制点的贝塞尔曲线，c1 和 c2 表示控制点，s和e表示曲线的开始和终止点：

<!DOCTYPE html>

<meta charset="utf-8" />

<style type="text/css">

body{text-align:center;}

#can{border:1px solid black}

</style>

<body>

<h2>贝塞尔曲线</h2>

<canvas id="can" width="400" height="300"></canvas>

</body>

<script>

var ctx = can.getContext("2d");

//定义Point对象

var Point = function(x, y){

this.x = x;

this.y = y;

}

//定义控制点，前面两个是开始和结束点，最后两个是控制点

var cPt =[];

//产生控制点

function createControlPt(x, y)

{

if(cPt.length<4)

{

cPt.push(new Point(x, y));

}

}

//绘制控制点

function drawPt()

{

for(var i=0;i<cPt.length;i++)

{

var c="red";

if(i<2)

{

c = "green";

}

ctx.strokeStyle = c;

ctx.strokeRect(cPt[i].x-5, cPt[i].y-5, 10, 10);

}

}

//判断一个点是否在一个以p2为中心的矩形中

function isInRect(p1, p2, w, h)

{

return p1.x>=p2.x-w&&p1.x<=p2.x+w&&p1.y>=p2.y-h&&p1.y<=p2.y+h;

}

//判断一个点在哪一个控制区域中

function getIdxCpt(p)

{

var idx=-1;

for(var i=0;i<cPt.length;i++)

{

if(isInRect(p, cPt[i], 5, 5))

{

return i;

}

}

return idx;

}

//绘制控制点和起始点连线

function drawBLine()

{

ctx.strokeStyle="gray";

ctx.beginPath();

ctx.moveTo(cPt[0].x, cPt[0].y);

ctx.lineTo(cPt[2].x, cPt[2].y);

ctx.stroke();

ctx.moveTo(cPt[1].x, cPt[1].y);

ctx.lineTo(cPt[3].x, cPt[3].y);

ctx.stroke();

}

//绘制贝塞尔曲线

function drawBei()

{

ctx.beginPath();

ctx.strokeStyle = "red";

ctx.moveTo(cPt[0].x, cPt[0].y);

ctx.bezierCurveTo(cPt[2].x, cPt[2].y, cPt[3].x, cPt[3].y, cPt[1].x, cPt[1].y);

ctx.stroke();

}

//绘制所有的图形

function draw()

{

drawPt();

if(cPt.length>3)

{

drawBLine();

drawBei();

}

}

//设置鼠标点下和移动事件

var selPt = new Point(-1, -1), sIdx;

can.onmousedown = function(e){

var x = e.offsetX, y = e.offsetY;

selPt.x = x;selPt.y = y;

createControlPt(x, y);

draw();

//判断是否点在控制点中

if(cPt.length>3)

{

sIdx=getIdxCpt(selPt);

if(sIdx>=0)

{

can.onmousemove=function(e){

cPt[sIdx].x = e.offsetX;

cPt[sIdx].y = e.offsetY;

ctx.clearRect(0, 0, 400, 300);

draw();

}

}

}

}

can.onmouseup = function(){

this.onmousemove = null;

}

</script>

</html>

注意在绘制所有图形之前一定要使用 clearRect()方法来清除屏幕，因为如果不清除屏幕，将会在屏幕上留下所有的绘制图像。

2.2.7 线条属性

在进行图形绘制的时候，线条有一些常用的属性会影响到线条的样式。

• lineWidth：该属性用来设置线条的粗细，默认为1个像素大小，小于0 的值将被忽略。

这里有一个比较经典的问题，就是绘制1像素大小的直线。如果绘制1像素大小的线条，看起来像2个像素，当直线呈水平或者垂直方向时，这个现象非常明显，这是为什么呢？W3C在canvas规范中解释到，当使用canvas绘制图形时候，它是由路径向两边扩展的，各占绘制线条宽度的一半，但 canvas 的坐标并不是直接和屏幕上的像素对应，假设需要绘制一条(3, 1)到(3, 5)的直线，把屏幕放大，得到图2-7。

图 2-7 中的每一个格子代表显示屏的一个像素，当绘制(3, 1)到(3, 5)的直线的时候，首先，路径就定位在屏幕中第三列像素和第四列像素的中间位置。此时，绘制 1 像素的时候，就需要从这条路径分别向两边扩展0.5个像素，但实际上显示屏是不可能绘制半个像素的，这个时候就只能同时绘制第三列和第四列两列像素。所以如果需要屏幕绘制一个像素大小的线，只需要把canvas的路径定位到某一个像素的中间位置，这时候刚好向两边扩展为一个像素，如图2-8所示。

图2-7 放大的屏幕

图2-8 放大的屏幕

所以，如果需要绘制1像素大小的直线，需要把坐标加上0.5的偏移，这时候就显示正常了，当然，如果画大于1像素的或者绘制斜线就没有必要额外处理了。

• lineCap：lineCap用来指定线条两端的端点，常用的值有3个，分别是butt（无端点）、round （圆端点）以及square（方端点），其中默认值是butt，三种样式显示的效果如图2-9所示。

• lineJoin：lingJoin 用来设置两条线连接的方式，常用值有round（圆角）、bevel（斜角）以及miter（尖角），其中miter是默认值，三种样式如图2-10所示。

图2-9 不同的lineCap

图2-10 不同的lineJoin

• miterLimit：当lineJoin为miter 时有效，表示的是斜面长度和线宽的比例，默认为10。

2.2.8 线条颜色

线条的颜色使用stokeStyle属性指定，颜色的值可以使用类似CSS的方式指定，比如红色可以采用以下3种方式：

context.stokeStyle = 'red'

context.stokeStyle = '#ff0000'

context.stokeStyle = 'rgba(255, 0, 0, 1.0)'

2.2.9 填充

前面所介绍的绘图方式都是适用描边处理（stroke），我们可以通过fill方法进行图形填充。

• fill()：该方法使用当前的fillStyle 填充当前路径，通过fillStyle = 颜色值可以指定填充的颜色，颜色表示和strokeStyle一样。

以下代码就填充了8个红色的同心圆:

<body>

<h2>填充圆形例子</h2>

<canvas id="can" width="400" height="300" >

</canvas>

</body>

<script>

//获取2d上下文

var ctx = can.getContext("2d");

var width = can.width,

height = can.height;

//计算圆心

var xOff = width*0.5,

yOff = height*0.5;

for(var i=1;i<=8;i++)

{

//以最里面矩形为中心，画同心圆，半径依次减少15

ctx.beginPath();

ctx.fillStyle = "rgba(255, 0, 0, "+(30*i)/500+")";

ctx.arc(xOff, yOff, 120-i*15, 0, Math.PI*2, true);

ctx.closePath();

ctx.fill();

}

</script>

最后的效果如图2-11所示。

图2-11 填充的同心圆

除了可以填充纯色以外，canvas还提供了填充渐变色以及填充贴图，先来看看渐变对象。

经常使用Photoshop处理图像的读者知道，在Photoshop中就有这种渐变工具，可以通过拖拉一条辅助线来实现渐变。在canvas中提供的渐变对象有两种，一种是线性渐变，另一种是径向渐变。

• createLinearGradient (x0, y0, x1, y1)：创建一个线性的渐变对象，开始点是(x0, y0)，结束点是(x1, y1)。

• createRadialGradient (x0, y0, r0, x1, y1, r1)：创建一个径向渐变对象，开始点以(x0, y0)为圆心，r0 为半径，结束点以(x1, y1)为圆心，r1为半径。

一旦创建完了渐变对象之后，就可以通过该对象的addColorStop()方法，在渐变的某一点中增加一个颜色值，这个点可以认为是关键点，这样，每个关键点之间的色彩就会出现渐变效果。

• addColorStop(offset, color)：offset表示偏移大小，值在0.0～1.0之间，其实就是一个百分比值；color 是使用类似CSS 字符串描述的色彩颜色，比如 addColorStop (0, "red")表示初始关键点是一个红色点。

当使用 fillStyle 属性指定一个渐变对象的时候，就可以使用渐变的方式填充路径了，以下代码以渐变对象填充了两个矩形区域。

<body>

<h2>渐变</h2>

<canvas id="can" width=600 height=300></canvas>

</body>

<script>

var ctx = can.getContext("2d");

var w = 480, h=60;

ctx.beginPath();

//创建线性渐变

var g = ctx.createLinearGradient(0, 0, 480, 0);

//创建径向渐变

var g1 = ctx.createRadialGradient(300, 160, 10, 300, 160, 240);

//设置颜色

g.addColorStop(0, "black");

g.addColorStop(1, "white");

ctx.fillStyle = g;

//绘制矩形

ctx.rect((600-w)*0.5, 30, w, 80);

ctx.fill();

ctx.beginPath();

//定义基本色

var colors=["aqua", "black", "blue", "fuchsia", "gray", "green", "lime", "maroon",

"navy", "olive", "purple", "red", "silver", "teal", "white", "yellow"];

var step = 1/colors.length;

for(var i=0;i<colors.length;i++)

{

g1.addColorStop(i*step, colors[i]);

}

//ctx.arc(300, 200, 100, 0, Math.PI*2, true);

//绘制矩形

ctx.rect((600-w)*0.5, 120, w, 80);

ctx.fillStyle = g1;

ctx.fill();

</script>

效果如图2-12所示。

以上是使用渐变颜色进行填充，另外一种填充方式是使用一张图片作为贴图进行填充，使用的API如下。

• createPattern (image，repetition)：image 表示需要填充的图像，可以是img、canvas、video元素等；repetition定义图像按照什么方式贴图，通常的贴图方式有以下几种。

repeat：水平和垂直方向重复贴图，默认值。

repeat-x：水平方向重复贴图。

repeat-y：垂直方向重复贴图。

no-repeat：使用一次贴图。

通过createPattern方法创建了一个模式对象后，就可以通过fillStyle或者strokeStyle等属性指定，然后就可以使用指定的图形进行填充。

以下代码创建了两个分别使用stroke()和fill()填充的图形：

<body>

<h2>Pattern</h2>

<canvas id="can" width="600" height="300"></canvas>

</body>

<script>

var ctx = can.getContext('2d');

var imgSrc =["img/t1.png", "img/f1.png"];

var ctx = can.getContext("2d");

//创建Image对象和pattern对象

for(var i=0;i<imgSrc.length;i++)

{

var img=new Image();

img.src = imgSrc[i];

img.onload = (function(im, i){

var self = im;

return function(){

var p = ctx.createPattern(self, "repeat");

if(i==0)

{

ctx.beginPath();

ctx.fillStyle = p;

ctx.fillRect(38, 38, 520, 232);

}

else

{

ctx.beginPath();

ctx.strokeStyle = p;

ctx.lineWidth = 18;

ctx.strokeRect(28, 28, 540, 250);

}

}

}(img, i));

}

</script>

效果如图2-13所示。

图2-12 渐变

图2-13 填充

2.2.10 绘图状态

conext中有一些全局的属性，如前面提到的strokeStyle、fillStyle、lineWidth等。当我们进行绘图的时候，有时，在改变这些值之前，需要保存上一次绘图的状态，下次绘制的时候又需要进行恢复，这种情况很常见。当然，不需要我们自己定义一个全局的对象进行保存，context中本身定义了以下方法用于保存和恢复canvas的状态。

• save()：把当前绘图状态压到绘图状态堆中。

• restore()：弹出绘图状态堆最上面保存的绘图状态。

状态堆中包含以下部分。

• 当前的transformation matrix（换矩阵）前的clipping region（区域）。

• 当前的属性值：fillStyle、font、globalAlpha、globalCompositeOperation、lineCap、lineJoin、lineWidth、miterLimit、shadowBlur、shadowColor、shadowOffsetX、shadowOffsetY、strokeStyle、textAlign、textBaseline。

为了避免本次绘图状态影响到下次绘图，通常情况下在绘图之前，都会使用 contex.save()方法保存当前绘图状态，绘制完成后再使用context.restore()进行恢复。

对于图形的操作，在HTML4时代可以使用SVG进行矢量绘图，而canvas除了支持矢量图形外，还可以直接针对图像以及像素操作，这才是canvas强大的地方。接下来，看看canvas关于图像处理的部分。

2.3 图像API

除了绘制常用的图形以外，canvas提供了一系列的API能够对图像进行操作，常见的图像API有以下3个方法。

• drawImage (image, dx, dy)：把image 图像绘制到画布上(dx, dy)坐标位置。

• drawImage (image, dx, dy, w, h)：把image 图像绘制到画布上(dx, dy)坐标位置，图像的宽度是w，高度是h。

• drawImage (image, sx, sy, sw, sh, dx, dy, dw,dh)：截取image 图像以(sx, sy)为左上角坐标，宽度为 sw，高度为 sh 的一块矩形区域绘制到画布上(dx, dy)坐标位置，图像宽度是dw，高度是dh。

其中 image 可以是 htmlImageElement 元素、htmlcanvasElement元素、htmlVideoElement元素， htmlVideoElement元素是HTML5中新增加的video视频播放元素。

图 2-14 显示了 drawImage 中源图像和目标canvas之间的关系。

图2-14 源图像和目标canvas之间的关系

采用后面两种方式进行绘制的时候可以实现图像的放大和缩小。

2.3.1 使用canvas 绘制图像

先看一个简单的例子，我们把<image/>标签中的图像显示到一个canvas中。

<body>

<h2>图片显示</h2>

<img src="img/t1.jpg" id="img1" />

<input type="button" id="btnCopy" value="拷贝图片" /><br>

<canvas id="can" width="400" height="300" ></canvas>

</body>

<script>

function $(id)

{

return document.getElementById(id);

}

$("btnCopy").onclick = function()

{

//获取canvas的上下文

var ctx = $("can").getContext("2d");

//设置canvas的大小

$("can").width = $("img1").width;

$("can").height = $("img1").height;

ctx.drawImage($("img1"), 0, 0);

}

</script>

代码中有一个图片元素、一个按钮和一个 canvas 元素，当点击按钮的时候首先获取了 id为img1的图片的宽度和高度，然后改变canvas的大小，最后使用drawImage方法把img的图片绘制到canvas 元素中，使用drawImage 把img1 绘制到canvas坐标(0, 0)的位置，大小和原图像一样大。

2.3.2 案例：放大镜

现在，我们可以借助drawImage方法实现一个简单的放大镜效果，效果如图2-15所示。

在这个放大镜效果中，我们可以在任意一张图片上点击，在该位置就会出现一个圆形的放大镜。实现原理也比较简单，首先，需要获取鼠标当前的位置，然后根据放大镜的尺寸，截取图片中相应大小的图片，最后通过drawImage方法把图片中的部分绘制到canvas上面。

这里涉及一个问题，放大镜是圆形的，但实际上如果直接通过canvas绘制，将最终以矩形的方式显示。这里，我们可以使用剪裁的方式进行绘制，所谓剪裁，就是在canvas中指定一块区域，在这个区域之内的都会显示，之外的一律不显示，所以如果我们把剪裁区域设置成圆形就可以了。canvas中使用context.clip()方法定义一个画布的剪裁路径，方法没有任何参数，它会使用当前的路径作为一个剪裁的区域，默认情况下，整个 canvas 本身就是一个剪裁路径。如图2-16所示，图中定义了一个圆形的剪裁区域，在圆形之外的区域将不会显示。

图2-15 放大镜

图2-16 圆形剪裁区域

好了，有了以上的基本知识，现在可以开始制作一个放大镜了，具体代码如下：

<body>

<h2>放大镜</h2>

<img src="img/tx1.jpg" id="s1"></img>

</body>

<script>

//根据编号获取对象

function $(id)

{

return document.getElementById(id);

}

//定义放大镜

var Glass = {

bind:function(imgId, zRat)

{

var self=this;

this.canvas = document.createElement("canvas");

this.canvas.style.display="none";

this.canvas.style.position="absolute";

this.ctx = this.canvas.getContext("2d");

this.canvas.width = 100;

this.canvas.height = 100;

this.hEle = $(imgId);

//设置放大比例

this.zRat = zRat|2;

//设置鼠标按下事件

document.body.appendChild(this.canvas);

document.body.onmousedown=function(e){

if(e.srcElement.id==imgId){

e.preventDefault();

draw(e);

//定义绘制方法

function draw(e)

{

//获取鼠标位置

var x = e.pageX, y = e.pageY;

//获取图片相对位置

var exOff = x-self.hEle.offsetLeft,

eyOff = y-self.hEle.offsetTop;

//设置获取图片周围长度

var rLen = 50/self.zRat;

self.copyImg(exOff-rLen, eyOff-rLen, rLen*2, rLen*2);

self.show(x-50, y-50);

}

document.body.onmousemove = draw;

document.body.onmouseup = function(){

self.hide();

document.body.onmousemove = null;

};

}

};

},

copyImg:function(x, y, w, h)

{

this.ctx.arc(50, 50, 50, 0, Math.PI*2, true);

//设置路径剪裁形成圆形

this.ctx.clip();

this.ctx.drawImage(this.hEle, x, y, w, h, 0, 0, 100, 100);

},

show:function(x, y)

{

this.canvas.style.display="block";

this.canvas.style.pixelLeft = x;

this.canvas.style.pixelTop = y;

},

hide:function()

{

this.canvas.style.display="none";

}

};

Glass.bind("s1");

</script>

除了实现静态的效果外，还可以通过drawImage实现动画效果。

2.3.3 案例：帧动画实现

来看一个比较复杂的例子，该例子中实现了一个简单的人物动画，先简单了解一下动画的原理。通常我们看到的动画称为逐帧动画，它是利用人眼睛视觉暂留的原理，即物体被移动后其形象在人眼视网膜上还可有约1秒的停留。利用这个原理，我们在一秒钟内如果连续放映20张静态的图片，这样就形成了动画的效果，当然 20 是最基本的要求，如果流畅的话至少要 30帧/秒，也就是说每秒钟放30张静态图片，每一帧图片称为一帧，在第4章的制作游戏引擎中会详细介绍动画机制。

本例子中我们有3张人物行走的图片，这3张图片构成了人物行走的过程，为了提高效率通常情况下制作动画的时候，会把人物的动作放在一张图片中，如图2-17所示（该图截取自游戏《超级玛丽》）。

图2-17 玛丽行走图片

这张图片包含了玛丽行走的动画，一共由3帧组成，我们在canvas上循环绘制这三张图片就形成了玛丽行走的动画，代码如下：

<body>

<h2>图片动画</h2>

<img id="img1" src="img/mr.png" />

<input id="btnGO" type="button" value="开始" /><br>

<canvas id="c1" width="320" height="200" ></canvas><br>

</body>

<script>

//是否开始动画

var isAnimStart = false, animHandle = null;//动画句柄

//保存每帧动画起始坐标，本例图片共有3帧

var frames=[

[0, 0],

[32, 0],

[64, 0]

];

//定义每帧图像的宽度和高度

var fWidth = 32, fHeight = 32;

function $(id)

{

return document.getElementById(id);

}

//开始

function init()

{

//注册GO按钮事件

$("btnGO").onclick=function()

{

//如果没开始动画，则开始动画

if(!isAnimStart)

{

//获取canvas上下文

var ctx = $("c1").getContext("2d");

//设置当前帧序号

var fIndex = 0;

//找到canvas的中点

var cX = 160, cY = 100;

animHandle = setInterval(function(){

//先清空画布

ctx.clearRect(0, 0, 320, 200);

//绘制当前帧

ctx.drawImage(img1, frames[fIndex][0], frames[fIndex][1], fWidth, fHeight, cX-64, cY-64, fWidth*4, fHeight*4);

//计算下一帧

fIndex++;

if(fIndex>=frames.length)

{

fIndex=0;

}

}, 100)

$("btnGO").value = "停止";

isAnimStart = true;

}

else

{

$("btnGO").value="开始";

clearInterval(animHandle);

isAnimStart = false;

}

}

}

init();

</script>

来看看代码功能，首先定义了几个变量，isAnimStart标记是否已经开始播放动画标记，用来切换播放和停止动画。animHandle保存定时器的句柄，可以通过它关闭定时器，定义frames数组变量记录图片中3个动作帧左上角的起始坐标，在绘制动画的时候，需要根据这个坐标来截取图片中的每帧图像。fWidth和fHeight分别表示每帧图片的宽度和高度，本例中使用的玛丽图像帧是32×32大小。然后定义init初始化方法，在该方法中绑定了开始按钮的事件，为了实现动画的播放，使用了serInterval方法，该方法每100毫秒执行一次，每次读取frames中的一帧图像，并把它显示在画布上，其核心代码是：

//先清空画布

ctx.clearRect(0, 0, 320, 200);

//绘制当前帧

ctx.drawImage(img1, frames[fIndex][0], frames[fIndex][1], fWidth，fHeight, cX-64,

cY-64, fWidth*4, fHeight*4);

这段代码首先使用ctx.clearRect方法清空整个画布，因为在定时器动画中需要每100毫秒更新画布中的内容，那么每次更新时，需要先把上个画面清除掉，然后再进行绘制，否则会把几张动画重叠起来。然后使用了ctx.drawImage方法，把img1图片中的内容复制到canvas上面，复制的时候第1个参数是选取的图片，第2～5的参数分别对应着img1图片上的一块矩形区域，最后4个参数对应着canvas上面的一块矩形区域，实际上就是把图片上的某一矩形区域部分绘制到canvas上面，由于画到canvas上面的宽度和高度分别是fWidth×4和fHeight×4，实际上也就是把原图像放大了4倍。

除了直接对图片元素操作外，canvas还提供了直接对像素元素进行处理的API。

2.3.4 像素操作

canvas另外一个非常强大的功能就是可以对图像中的任一个像素进行处理。我们知道图像实际上是由很多像素点组成，一幅宽度为320，高度200的图片是由320×200=64 000 个像素点构成，每个像素由red、green、blue三种颜色组成。

canvas提供了以下API让我们可以进行像素操作。

• getImageData (sx, sy, sw, sh)：获取canvas 上以(sx, sy)为左上角，宽度为sw，高度为sh的一块矩形区域的像素数据。通过getImageData获取到了imageData对象，该对象有以下3个属性。

width：每行的像素数量。

height：每列的像素数量。

data：存有从canvas中获取的每个像素的RGBA的值，该数组为每个像素保存了四个值，分别是红色、绿色、蓝色和alpha透明度，每个值在0～255之间，数组填充的数据是从上到下，从左到右，比如 imgData.data[0]～imgData.data[3]就保存了canvas 图像中左上角第一个像素点的 RGBA 的数据， imgData.data[4]～imgData.data[7]保存了canvas图像中左上角第二个像素点的RGBA的数据，依次类推，从左到右，从上到下。

• createImageData (sw, sh)：创建一个宽度为sw，高度为sh的imageData对象，该对象中所有的像素都是黑色的。

• createImageData (imageData)：创建一个 imageData 对象的副本，像素值和imageData的一致。

• putImageData (imageData, dx, dy, [dirtyX, dirtyY, dirtyWidth, dirtyHeight])：在绘图画布上绘制给定的 ImageData 对象。假如脏矩形被提供，则只有在脏矩形上面的像素被绘制。本方法对全局透明、阴影和全局组合等属性均忽略。

data数组中的数据可以获取也可以设置，我们可以把data中的数据进行修改后重新绘制达到修改图像的目的。

利用可以对像素操作的特性，我们可以完成一些简单的图像处理效果，类似于 Photoshop中的滤镜效果，如转成灰度图、浮雕效果等，接下来看看相关的例子。

2.3.5 案例：转换灰度图

现在来看一个简单的像素操作的例子，该例子中会把任意一张彩色图片转换成灰度图片，如图2-18所示。

图2-18 灰度图

彩色图像要转换成灰度图，需要对图中的每一个像素点进行处理，我们可以通过 getImageData()方法获取每个点的像素值，然后把该点的色彩转成灰度。彩色转灰度的算法很多，这里，我们采用以下算法：

灰度值=(R×30 + G×59 + B×11 + 50) / 100

具体实现代码如下：

<body>

<h2>像素操作</h2>

<img id="img1" src="img/t1.jpg" />

<input id="btnGO" type="button" value="转成灰度图" /><br>

<canvas id="c1" width="320" height="200" ></canvas><br>

</body>

<script>

function $(id)

{

return document.getElementById(id);

}

//开始

function init()

{

//注册GO按钮事件

$("btnGO").onclick=function()

{

c1.width=img1.width;

c1.height = img1.height;

//先把image绘制到canvas上

var ctx = c1.getContext("2d");

ctx.drawImage(img1, 0, 0, c1.width, c1.height);

//获取像素数据

var imgData = ctx.getImageData(0, 0, c1.width, c1.height);

for(var i = 0;i<imgData.data.length;i+=4)

{

//获取RGB像素值

var r = imgData.data[i],

g = imgData.data[i+1],

b = imgData.data[i+2];

//计算灰度值，常用公式 Gray = (R*30 + G*59 + B*11 + 50) / 100

var gray = (r*30+g*59+b*11+50)*0.01;

imgData.data[i] = gray;

imgData.data[i+1] = gray;

imgData.data[i+2] = gray;

}

//最后把imgdata数据绘制到canvas中

ctx.putImageData(imgData, 0, 0);

}

}

init();

代码先通过contex.drawImage把图像绘制到canvas上，需要注意的是，只有canvas上有图像了才能通过getImageData获取canvas的图像数据，然后定义imgData变量得到canvas的图像数据，接着通过循环imgData数据，修改canvas图像中的每个像素数据。因为每个图像数据由4个值构成，所以循环data数据每次加4，然后接着获取每个像素的RGB值，然后通过公式转成灰度。

把彩色信息转成灰度信息后，最后重新设置每个像素的颜色值，就完成了彩色图到灰度图的转变。我们虽然改变了图像的像素值，但是还不能让图像立刻变成我们想要的样子，还需要重新把像素的数据绘制到canvas中，所以我们使用了putImageData方法。

这样就完成了整个的图像转变过程。

2.3.6 案例：浮雕效果

接下来，来实现一个浮雕效果，浮雕效果如图2-19所示。

图2-19 浮雕图

浮雕效果的实现原理也有很多种，常用的算法是把每个点周围的8个点和一个转换矩阵进行卷积操作，得到的值作为该点的新色彩。但计算量过大，这里采用一种相对简单的算法，该算法是这样的，对于任一点的像素来说，新的色彩值等于该点的色彩和右边像素的色彩值相减，然后加上128。

具体实现如下：

<body>

<h2>像素操作</h2>

<img id="img1" src="img/t1.jpg" />

<input id="btnGO" type="button" value="转成浮雕图" />

<canvas id="c1" width="320" height="200" ></canvas><br>

</body>

<script>

function $(id)

{

return document.getElementById(id);

}

//开始

function init()

{

//注册GO按钮事件

$("btnGO").onclick=function()

{

c1.width=img1.width;

c1.height = img1.height;

//先把image绘制到canvas上

var ctx = c1.getContext("2d");

ctx.drawImage(img1, 0, 0, c1.width, c1.height);

//获取像素数据

var imgData = ctx.getImageData(0, 0, c1.width, c1.height);

var iData = imgData.data;

for(var i = 0;i<img1.height-1;i++)

{

for(var j=0;j<img1.width;j++)

{

//获取像素在dataImage起始位置

var start = (i*img1.width+j)<<2;

var r = iData[start]-iData[start+4]+128,

g = iData[start+1]-iData[start+5]+128,

b = iData[start+2]-iData[start+6]+128;

//越界处理

r = (r<0)?0:(r>255)?255:r;

g = (g<0)?0:(g>255)?255:g;

b = (b<0)?0:(b>255)?255:b;

//转灰度图

var g=(r*30+g*59+b*11+50)*0.01;

iData[start] = g;

iData[start+1] = g;

iData[start+2] = g;

}

}

//最后把imgdata数据绘制到canvas中

ctx.putImageData(imgData, 0, 0);

}

}

init();

</script>

需要注意的是，在得到新的像素点色彩值之后，需要进行越界处理，并转成灰度色彩，否则，该像素点还有可能是彩色点。

接下来，来看看坐标变换相关的一些知识。

2.4 坐标变换

在绘制图像的过程中，经常可能需要对图像进行旋转、缩放等变形处理，canvas也提供了一系列的API帮助我们完成这些操作。

关于画布的坐标变换，canvas提供了以下常用的API，这些API的操作必须要在绘制之前调用，否则不会产生任何效果。

• translate (x, y)：平移，把画布的原点坐标移动到(x, y)位置，x 表示将坐标原点向左移x个像素，y表示将坐标原点向下移动y个像素。正常情况下canvas的原点坐标位于左上角，那么我们可以通过translate方法移动原点的坐标，比如以下代码就把原点坐标移动到了canvas的中间位置：

context.translate(canvas.width*0.5, canvas.height*0.5);

• scale (x, y)：缩放，把画布放大。x表示水平方向放大倍数，y表示垂直方向放大倍数，如果需要缩小，将这两个参数设置为0～1之间的数就可以了，比如context.scale (0.5, 0.5)表示把图形缩小一半。

• rotate (angle)：把图形进行旋转。angle表示旋转的角度，旋转的中心点是原点，旋转是以顺时针方向进行，angle 设置成负数就是逆时针旋转。需要注意的是 angle 是以弧度表示，比如要顺时针旋转90 度可以使用context.rotate (Math.PI*0.5)。

• transform (m11, m12, m21, m22, dx, dy)：把当前的矩阵乘上如图2-20 所示的矩阵，做矩阵叠加操作。

• setTransform (m11, m12, m21, m22, dx, dy)：设置当前的变换矩阵为图2-20 所示的矩阵。

以下代码在canvas的中间位置绘制了棵小树：

<body>

<h2>图片显示</h2>

<img src="img/t1.png" id="img1" /><br>

<canvas id="can" width="300" height="200"></canvas>

</body>

<script>

function $(id)

{

return document.getElementById(id);

}

document.body.onload = function()

{

//获取canvas的上下文

var ctx = $("can").getContext("2d");

//获取canvas中心

var cx = $("can").width*0.5,

cy = $("can").height*0.5;

//获取图片的宽高度

var iw = $("img1").width,

ih = $("img1").height;

//平移到中心

ctx.translate(cx, cy);

ctx.drawImage($("img1"), -iw*0.5, -ih*0.5);

}

</script>

代码中，首先计算出canvas 的中心坐标(cx, cy)，然后把canvas画布原点移动到点(cx, cy)。由于画布的原点移动到点(cx, cy)位置，那么，drawImage (img, dx, dy)表示的是把图片以(dx, dy)为左上角开始绘制。所以，如果要绘制到中央，需要偏移半个宽度和高度，即使用坐标点(−w/2, −h/2)。最终效果如图2-21 所示，黑线表示canvas 的边框。

图2-20 变换矩阵

图2-21 canvas中间绘制图片

rotate 方法描述的是图像绕原点旋转，假设要实现绕某一个图像的中心点旋转，如何实现呢？对了，我们可以先把画布的原点移动到图像的中心点，然后就可以进行旋转操作了。

以下代码绘制了在画布中央，一个绕自身中心旋转并且放大了一倍的小树。

<body>

<h2>图片显示</h2>

<img src="img/t1.png" id="img1" /><br>

<canvas id="can" width="300" height="200"></canvas>

</body>

<script>

function $(id)

{

return document.getElementById(id);

}

//定义旋转角度

var deg = 0;

//绘制图像

function draw()

{

//保存变换矩阵

ctx.save()

//清除屏幕

ctx.clearRect(0, 0, 300, 200);

ctx.translate(cx, cy);

ctx.scale(2, 2);

ctx.rotate(deg*Math.PI/180);

ctx.drawImage($("img1"), -iw*0.5, -ih*0.5);

//恢复变换矩阵

ctx.restore();

}

document.body.onload = function()

{

//获取canvas的上下文

ctx = $("can").getContext("2d");

//获取canvas中心

cx = $("can").width*0.5,

cy = $("can").height*0.5;

//获取图片的宽高度

iw = $("img1").width,

ih = $("img1").height;

window.setInterval(function(){

deg+=2;

draw();

}, 30);

}

</script>

这段代码中，我们定义了一个 draw 方法，用于绘制图像，在绘制图像的过程中，首先保存当前的绘图状态，然后把画布移动到中心，进行旋转和放大，最后恢复画布状态。注意，如果不进行保存和恢复状态的操作，则由于使用这些变换操作都将改变当前变换矩阵的状态，从而影响下一次的变换。

定义了draw方法后，设置了一个定时器，每30毫秒改变全局的角度，形成一个旋转的动画，具体的效果如图2-22所示。

图2-22 绕自身旋转的小树

本质上，前面所介绍的 rotate()、scale()、translate()等方法最终都会转换成矩阵的形式进行操作，这种对应如下所示：

translate(x, y) ⇒ transform(1, 0, 0, 1, x, y)

scale(x, y) ⇒ transform(sx, 0, 0, sy, 0, 0)

rotate(deg) ⇒ transform(cos(deg), sin(deg), -sin(deg), cos(deg), 0, 0)

由于本质上这些变换操作都会进行矩阵和三角函数的相关运算，特别是旋转操作，所以这些变换操作将消耗更多的时间，特别是在手持设备上使用更为明显。

2.5 绘制文字

canvas中除了可以绘制图形图像外，还可以绘制文字，同时也可以指定文字的字体、大小、对齐方式以及填充文字的纹理。

• fillText (text, x, y, [maxWidth])：在canvas 上填充文字，text表示需要绘制的文字，x、y分别表示绘制在 canvas 上的横、纵坐标，最后一个参数可选，表示显示文字的最大宽度，防止文字显示溢出。

• strokeText (text, x, y, [maxWidth])：在canvas 上描边文字，参数的意义同fillText。相关的文字的一些属性如下所示。

• font：字体属性，可以采用类似CSS中font定义的写法，比如bold 40px sans-serif表示文字是粗体，大小是40像素，字体是sans-serif。

• textAlign：对齐方式，常见的值如表2-2 所示。

表2-2 textAlign值

• textBaseline：文字对齐基线，常见的值如表2-3 所示。

表2-3 textBaseline值

比如，以下代码显示了Welcome To HTML5的文字：

<body>

<h2>显示文字</h2>

<canvas id="can" width="400" height="300" />

</body>

<script>

function $(id)

{

return document.getElementById(id);

}

//获取canvas的上下文

var ctx = $("can").getContext("2d");

ctx.strokeStyle = "red";

ctx.font = "bold 40px sans-serif";

ctx.strokeText("Welcome To HTML5", 10, 50);

</script>

代码中通过strokeStyle设置文字的颜色为红色，然后通过font设置文字的字体，最后通过strokeText描绘出文字的边框。

2.6 其他全局属性

在context中还有一些常见的全局属性，做一些了解。

• globalAlpha：透明度，这个值用来设置在画布上绘制的透明度，值的范围从0～1之间，使用这个属性我们可以完成一些常见的效果，比如游戏中常见的淡入淡出效果。

以下代码展示了一张图片淡入，也就是逐渐显示的效果。

<body>

<h2>淡入</h2>

<img src="img/t1.jpg" id="img1" style="display:none" /><br>

<canvas id="can" width="300" height="200"></canvas>

</body>

<script>

function $(id)

{

return document.getElementById(id);

}

function draw()

{

//清除屏幕

ctx.drawImage($("img1"), 0, 0);

}

document.body.onload = function()

{

//获取canvas的上下文

ctx = $("can").getContext("2d");

//获取图片的宽高度

$("can").width = $("img1").width,

$("can").height = $("img1").height;

ctx.globalAlpha = 0;

var tHandle = window.setInterval(function(){

ctx.globalAlpha+=0.05;

draw();

if(ctx.globalAlpha>=0.95)

{

window.clearInterval(tHandle);

}

}, 100);

}

</script>

• globalCompositeOperation：全局混合模式，这个属性定义了如果在画布上绘制多个图像时，图像进行叠加的方式，也称为混合模式，类似于Photoshop这种图像处理软件中图层的叠加模式。表2-4列出了混合模式的可能值，表2-4中，正方形表示目标图像，圆形表示源图像。

表2-4 混合模式

2.7 案例：《你画我猜》

在这一小节中，我们将利用前面介绍的知识，来创作一个《你画我猜》游戏中的主要功能。《你画我猜》是一款老少皆宜的多人在线的网络游戏，2012 年风靡一时，玩法其实也来源于生活当中，经常在娱乐节目中出现。通常在节目中是这样玩的，主持人写出一个词语，然后由一个参与者根据这个词语画出相应的图案，由另一个参与者来根据这个图案猜出这个词语，而《你画我猜》就把现实生活中的这个玩法转到了电脑上，玩法就是这么简单。当然，本章还无法开始做出一个网络游戏，哪怕是一个简单的单机游戏，本书第10章有《你画我猜》这个游戏完整的实现，本章将会完成《你画我猜》中的画板部分。没玩过《你画我猜》游戏的，也应该用过Windows的画板吧？好了，下面我们来实现这个画板。

2.7.1 UI 界面设计

首先，我们来设计一个画板的主页面，关于使用div布局游戏的UI不是本书的重点，我们只关注游戏逻辑的实现，以下代码只是一个参考实现：

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

<title>你画我猜</title>

<link href="images/CSS.css" rel="stylesheet" type="text/css">

</head>

<body>

<!--主容器层-->

<div id="main" class="main">

<div>

<div class="tx"></div>

<div class="wz">

<div id="dround"></div>

<!--显示问题区域-->

<span id="question"></span>

</div>

<!--显示时间-->

<div id="qTime" class="time">60</div>

<div class="tc">离开房间<img src="images/edit_undo.png" align="absmiddle"></div>

</div>

<div class="qc"></div>

<div>

<!--绘图主层-->

<div class="hbbox">

<!--绘图区域-->

<div id="hb" class="hb">

<canvas id="paintArea" width="525" height="370" ></canvas>

</div>

</div>

<!--工具选项区-->

<div class="hbr">

<div id="operDiv">

<!--颜色选取区-->

<div id="ys" class="ys"></div>

<!--画笔大小选择区-->

<div id="bc" class="bc"></div>

<!--定义橡皮擦工具-->

<div id="ssx" class="tb">

<div id="btnRub" class="tb2"><img src="images/01945.png"></div>

<div id="btnClear" class="tb3"><img src="images/053753321.gif"></div>

</div>

</div>

<!--消息发布主层-->

<div id="msgArea" class="msgArea"></div>

<div class="bd">

<input type="button" id="btnSendMsg" value="发送"><input type="text" size="13"

id="txtMsg">

</div>

</div>

</div>

</div>

</body>

设计好以上的主页面后，得到如图2-23所示的图，当然，样式部分不是重点，所以忽略，我们需要了解这个画板中和程序相关的部分。

好了，定义好图2-23所示的UI界面后，开始实现功能。

图2-23 《你画我猜》画板

2.7.2 定义画板对象

首先，我们把整个画板看成一个画板对象，这个画板对象中有一些基本的属性，如画板的大小，当前画笔使用的颜色、宽度等，于是就有了以下的Paint类：

var Painter = {

//绑定的环境上下文

ctx:null,

//宽度

w:0,

//高度

h:0,

//当前画笔颜色

bColor:null,

//当前画笔大小

bWidth:null

}

画板定义好了以后，我们需要逐渐往里面增加相应的功能代码。

2.7.3 初始化画笔选项区

接下来，我们需要动态产生颜色区域以及画笔区域，因为在前面设计 UI 的时候，只给这两个区域留下了空白容器，需要我们通过代码来产生。于是，可以给Painter定义一个初始化画笔的方法，具体的代码如下：

//初始化画笔

initBrush:function()

{

//定义画板颜色

var bColor = ["#000000", "#999999", "#FFFFFF", "#FF0000", "#FF9900", "#FFFF00",

"#008000", "#00CCFF", "#0099FF", "#FF33CC", "#CC66FF", "#FFCCCC", "#6633FF", "#CCFFCC"];

var bDiv = $("#ys"),

self = this;

//产生颜色层

for(var i=0;i<bColor.length;i++)

{

var b = $("<div class='bys'></div>").css("background-color", bColor[i]);

//修改颜色

b.on("click", function(){

//触发更新画板状态事件

self.fire("onPaintUpdate", {"color":$(this).css("background-color")});

});

bDiv.append(b);

}

//绑定画笔大小

var bWidth = [2, 8, 16, 24];

var bcDiv = $("#bc");

for(i = 0;i<bWidth.length;i++)

{

var bw = $("<div class='bwid' data-bidx='"+(i)+"'></div>");

bw.css("background-image", "url(images/bc"+(i+1)+".png)");

//修改画笔大小

bw.on("click", function(){

//触发更新画板状态事件

self.fire("onPaintUpdate", {"width":bWidth[this.getAttribute("data-bidx")]});

});

bcDiv.append(bw);

}

}

以上函数中，我们创建了颜色区域和画笔区域中的选择层部分，当用户鼠标点击颜色选择区域的时候，就会使用fire方法触发一个onPaintUpdate事件，修改当前用户画笔的颜色。同样，当用户鼠标点击画笔选择区域时，也会触发onPaintUpdate事件，修改当前用户画笔的大小。fire方法是Paint类中自定义的一个方法，用于处理画板中的各种自定义事件的触发，具体实现如下：

//触发画板事件

fire:function(eventName, param)

{

if(this[eventName])

{

this[eventName](param);

}

}

本质上，fire方法就是调用相应的事件处理程序，相应的，我们需要添加一个onPatintUpdate方法，相关代码如下：

//画板更新事件，当画板的参数比如画笔颜色，大小改变时触发

onPaintUpdate:function(data)

{

var w=data.width||this.bWidth,

c = data.color||this.bColor;

var param = {"width":w, "color":c};

//设置画笔大小

this.setBrushWidth(w);

//设置画笔颜色

this.setBrushColor(c);

}

在这个更新画笔的方法中，需要根据传入的参数调用 setBrushWidth()和 setBrushColor()方法更新当前画笔的色彩和大小，这两个方法的具体代码如下：

//设置画笔颜色

setBrushColor:function(color)

{

this.bColor=color||"black";

this.ctx.strokeStyle = this.bColor;

},

//设置画笔宽度

setBrushWidth:function(width)

{

this.bWidth=width||1;

this.ctx.lineWidth = this.bWidth;

}

到此为止，关于画笔颜色和大小的选择已经完成，接下来，我们看看如何实现用户在画板上用画笔绘画。

2.7.4 实现画板绘制

使用画笔在画板上绘制，从原理上来说，canvas上是可以画图形的，所以我们只要在鼠标的移动事件上面做文章就可以了。鼠标在canvas上面移动的时候，可以跟踪当前鼠标的坐标，然后在这些坐标点上绘制点就可以了，这是我们最初的想法，但实际上这种效果并不理想。因为，首先，canvas 并没有提供画点的方法，当然，可以通过绘制一个极小的矩形实现，但这不是主要问题。主要问题在于，虽然我们移动鼠标是连续移动，但电脑没有足够高的灵敏度捕捉鼠标的移动轨迹。事实上，如果采用这种方法，将会在canvas上留下断断续续的点的轨迹，这不是我们要的结果，我们需要的是把这些点用线连起来。所以最终的算法是，捕捉鼠标移动的坐标，然后把当前点和上一个点用线连接起来，最终，就可以达到一个比较好的效果。

根据以上的原理部分，我们在Paint类中增加一个initCanvas()方法，用于初始化画板，绑定鼠标在canvas上面的相关事件，其实现代码如下：

//初始化画板

initCanvas:function()

{

//绑定绘图canvas

var can = $("#paintArea"), self = this;

//绑定鼠标按下时间

can.on("mousedown", function(e){

e.preventDefault();

this.x = e.offsetX,

this.y = e.offsetY;

self.fire("onStartDraw", {"x":this.x, "y":this.y});

//绑定鼠标移动事件

can.on("mousemove", function(e){

var nx = event.offsetX, ny = event.offsetY;

self.fire("onDrawing", {"x":nx, "y":ny});

this.x = nx;

this.y = ny;

});

//绑定鼠标抬起事件

can.on("mouseup", function(){

//取消鼠标移动事件

can.off("mousemove");

});

})

}

这里，我们在canvas中绑定了鼠标按下、移动和抬起事件，在按下和移动的时候，触发了onStartDraw和onDrawing方法，这两个方法主要用于绘制线条，代码如下：

//开始画画事件

onStartDraw:function(data){

//开始路径

this.ctx.beginPath();

this.ctx.moveTo(data.x, data.y);

},

//画画事件

onDrawing:function(data)

{

this.ctx.lineTo(data.x, data.y);

this.ctx.stroke();

}

主要绘制部分的工作，基本都完成了。噢，别忘了，还有一个擦除区域。当我们绘制错误的时候，需要擦除图像，于是，我们可以完成初始化擦除区域的方法如下：

//初始化橡皮擦

initEraser:function()

{

var self=this;

//绑定清除屏幕事件

$("#btnClear").click(function(){

self.clear();

});

//擦除

$("#btnRub").click(function(){

self.setBrushColor("white");

self.setBrushWidth(32);

});

}

好了，大功告成，为了让Paint类开始工作，我们最好写一个init()方法，完成以上全部的初始化工作，以便让用户方便使用，init()方法可参考完整代码部分。

2.7.5 整合代码

好了，整个Painter对象就完成了，最终，我们得到的Paint对象的完整代码如下：

(function(){

//定义画板对象

var Painter = {

//绑定的环境上下文

ctx:null,

//宽度

w:0,

//高度

h:0,

//当前画笔颜色

bColor:null,

//当前画笔大小

bWidth:null,

//初始化

init:function()

{

var can=$("#paintArea")[0];

this.ctx = can.getContext("2d");

this.w = can.width;

this.h = can.height;

this.setBGColor();

this.setBrushColor();

this.setBrushWidth();

this.ctx.lineCap = "round";

this.ctx.lineJoin = "round";

//初始化画板事件

this.initCanvas();

//初始化画笔颜色

this.initBrush();

//初始化橡皮擦

this.initEraser();

},

//初始化画笔

initBrush:function()

{

//定义画板颜色

var bColor = ["#000000", "#999999", "#FFFFFF", "#FF0000", "#FF9900", "#FFFF00",

"#008000", "#00CCFF", "#0099FF", "#FF33CC", "#CC66FF", "#FFCCCC",

"#6633FF", "#CCFFCC"];

var bDiv = $("#ys"),

self = this;

//产生颜色层

for(var i=0;i<bColor.length;i++)

{

var b=$("<div class='bys'></div>").css("background-color", bColor[i]);

//修改颜色

b.on("click", function(){

//触发更新画板状态事件

self.fire("onPaintUpdate", {"color":$(this).css("background-color")});

});

bDiv.append(b);

}

//绑定画笔大小

var bWidth = [2, 8, 16, 24];

var bcDiv = $("#bc");

for(i = 0;i<bWidth.length;i++)

{

var bw=$("<div class='bwid' data-bidx='"+(i)+"'></div>");

bw.css("background-image", "url(images/bc"+(i+1)+".png)");

//修改画笔大小

bw.on("click", function(){

//触发更新画板状态事件

self.fire("onPaintUpdate", {"width":bWidth[this.getAttribute("data-bidx")]});

});

bcDiv.append(bw);

}

},

//初始化橡皮擦

initEraser:function()

{

var self=this;

//绑定清除屏幕事件

$("#btnClear").click(function(){

self.clear();

});

//擦除

$("#btnRub").click(function(){

self.setBrushColor("white");

self.setBrushWidth(32);

});

},

//设置背景颜色

setBGColor:function(color){

this.ctx.fillStyle = color||"white";

this.ctx.fillRect(0, 0, this.w, this.h);

},

//设置画笔颜色

setBrushColor:function(color)

{

this.bColor=color||"black";

this.ctx.strokeStyle = this.bColor;

},

//设置画笔宽度

setBrushWidth:function(width)

{

this.bWidth=width||1;

this.ctx.lineWidth = this.bWidth;

},

//初始化画板

initCanvas:function()

{

//绑定绘图canvas

var can = $("#paintArea"), self = this;

//绑定鼠标按下时间

can.on("mousedown", function(e){

e.preventDefault();

this.x = e.offsetX,

this.y = e.offsetY;

self.fire("onStartDraw", {"x":this.x, "y":this.y});

//绑定鼠标移动事件

can.on("mousemove", function(e){

var nx = event.offsetX, ny = event.offsetY;

self.fire("onDrawing", {"x":nx, "y":ny});

this.x = nx;

this.y = ny;

});

//绑定鼠标抬起事件

can.on("mouseup", function(){

//取消鼠标移动事件

can.off("mousemove");

});

})

},

//清除canvas

clear:function()

{

this.ctx.clearRect(0, 0, this.w, this.h);

},

//触发画板事件

fire:function(eventName, param)

{

if(this[eventName])

{

this[eventName](param);

}

},

//开始画画事件

onStartDraw:function(data){

//开始路径

this.ctx.beginPath();

this.ctx.moveTo(data.x, data.y);

},

//画画事件

onDrawing:function(data)

{

this.ctx.lineTo(data.x, data.y);

this.ctx.stroke();

},

//画板更新事件，当画板的参数比如画笔颜色，大小改变时触发

onPaintUpdate:function(data)

{

var w=data.width||this.bWidth, c=data.color||this.bColor;

var param = {"width":w,"color":c};

//设置画笔大小

this.setBrushWidth(w);

//设置画笔颜色

this.setBrushColor(c);

}

}

//画板初始化

Painter.init();

window.Painter = Painter;

}())

以上的Paint类在painter.js文件中，最后，完整的主页面代码index.htm如下：

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

<title>你画我猜</title>

<link href="images/CSS.css" rel="stylesheet" type="text/css">

</head>

<body>

<!--主容器层-->

<div id="main" class="main">

<div>

<div class="tx"></div>

<div class="wz">

<div id="dround"></div>

<!--显示问题区域-->

<span id="question"></span>

</div>

<!--显示时间-->

<div id="qTime" class="time">60</div>

<div class="tc">离开房间<img src="images/edit_undo.png" align="absmiddle"></div>

</div>

<div class="qc"></div>

<div>

<!--绘图主层-->

<div class="hbbox">

<!--绘图区域-->

<div id="hb" class="hb">

<canvas id="paintArea" width="525" height="370" ></canvas>

</div>

</div>

<!--工具选项区-->

<div class="hbr">

<div id="operDiv">

<!--颜色选取区-->

<div id="ys" class="ys"></div>

<!--画笔大小选择区-->

<div id="bc" class="bc"></div>

<!--定义橡皮擦工具-->

<div id="ssx" class="tb">

<div id="btnRub" class="tb2"><img src="images/01945.png"></div>

<div id="btnClear" class="tb3"><img src="images/053753321.gif"></div>

</div>

</div>

<!--消息发布主层-->

<div id="msgArea" class="msgArea"></div>

<div class="bd">

<input type="button" id="btnSendMsg" value="发送"><input type="text" size="13"

id="txtMsg">

</div>

</div>

</div>

</div>

</body>

<script src="../js/jquery.js" charset="utf-8"></script>

<script src="../js/painter.js" charset="utf-8"></script>

<script>

//检测是否支持HTML5

function isSupportHTML5()

{

return (typeof(Worker) !=="undefined");

}

//初始化

$(document).ready(function(){

if(!isSupportHTML5())

{

alert("您的浏览器不支持HTML5，请更换浏览器!");

}

else

{

$("#main").show();

}

});

</script>

</html>

至此，《你画我猜》的绘制部分已经完成，至于多人游戏的部分，在学习了后面的网络编程部分后，我们会得到一个完整的案例。

2.8 小结

本章介绍了canvas的一些常用的操作，作为HTML5的最重要的特性，canvas使得在Web中开发免插件的较高性能的游戏提供了条件，接下来的第3章中，我们介绍HTML5中的多媒体元素。

第3章 多媒体元素

游戏的世界应该是充满欢声笑语的，音乐有时候带给人的震撼甚至超过了视觉效果。这一章我们将继续讨论HTML5中两个非常重要的元素audio和video。在HTML5之前，播放音频和视频通常情况下是需要安装flash插件进行播放，HTML5的出现使得这一局面得到改观，不再需要额外地安装插件就可进行播放，同时，HTML5也提供了API可以通过JavaScript脚本控制播放。本章围绕多媒体元素包含以下几个主要方面（熟悉本章内容的读者可略过本章）：

• 多媒体基本知识；

• 使用多媒体元素；

• 常见的多媒体元素API。

3.1 多媒体基本知识

HTML5中提供了专门的audio元素和video元素，用于播放网络音频文件和视频文件。有了这两个多媒体元素，将不再需要插件就可以进行影音的播放，减少了浏览器的污染程度，我们先来了解一下关于音视频的基本知识。

3.1.1 多媒体容器

我们通常所说的视频文件格式实际上可以看成是一个容器，这个容器包含了如音频轨道、视频轨道和其他的一些元数据，元数据包含了视频的一些基本信息，比如封面、标题、字幕等。

百度百科中记录了以下常见的视频格式。

• FLV（Flash Video）：由Adobe Flash延伸出来的的一种流行网络视频封装格式。随着视频网站的丰富，这个格式已经非常普及，文件扩展名是flv。

• AVI（Audio Video Interleave）：比较早的 AVI 是微软开发的。其含义是 Audio VideoInteractive，就是把视频和音频编码混合在一起存储，文件扩展名是avi。

• WMV（Windows Media Video）：微软公司开发的一组数字视频编解码格式的通称，ASF （Advanced Systems Format）是其封装格式，文件扩展名是wmv/asf、wmvhd。

• MPEG（Moving Picture Experts Group）：是一个国际标准化组织（ISO）认可的媒体封装形式，受到大部分机器的支持。其存储方式多样，可以适应不同的应用环境。

• RM（Real Video或者称Real Media）：是由RealNetworks开发的一种容器。它通常只能容纳Real Video和Real Audio编码的媒体。该格式有一定的交互功能，允许编写脚本以控制播放。RM，尤其是可变比特率的RMVB格式，没有复杂的Profile/Level，制作起来较H.264视频格式简单，非常受网络上传者的欢迎。此外很多人仍有RMVB体积小质量高的错误认知，这个不太正确的观念也导致很多人倾向使用RMVB，事实上在相同码率下，RMVB编码和H.264这个高度压缩的视频编码相比，体积会较大。文件扩展名是rm/rmvb。

• QuickTime File Format是由苹果公司开发的容器。1998年2 月11 日，国际标准化组织（ISO）认可QuickTime文件格式作为MPEG-4标准的基础。QT可存储的内容相当丰富，除了视频、音频以外还可支持图片、文字（文本字幕）等。文件扩展名是mov、qt。

• Ogg Media 是一个完全开放性的多媒体系统计划，OGM（Ogg Media File）是其容器格式。OGM可以支持多视频、音频、字幕（文本字幕）等多种轨道。文件扩展名是ogg。

音频容器也是一样，只不过去掉了视频轨道的信息。

常见的音频格式如下。

• 无损格式，如WAV、PCM、TTA、FLAC、AU、APE、TAK、WavPack（WV）。

• 有损格式，如MP3、Windows Media Audio（WMA）、Ogg Vorbis（OGG）、AAC。

有损文件格式是基于声学心理学的模型，除去人类很难或根本听不到的声音。例如，一个音量很高的声音后面紧跟着一个音量很低的声音。MP3就属于这一类文件。

无损的音频格式（如TTA）压缩比大约是2:1，解压时不会产生数据/质量上的损失，解压产生的数据与未压缩的数据完全相同。如需要保证音乐的原始质量，应当选择无损音频编解码器。例如，用免费的TTA无损音频编解码器你可以在一张DVD-R碟上存储相当于20张CD的音乐。

有损压缩应用很多，但在专业领域使用不多。有损压缩具有很大的压缩比，提供相对不错的声音质量。

3.1.2 音视频解码器

需要分清楚的是音视频文件和编解码器不同。尽管一种音视频文件格式可以支持多种编码，例如AVI文件格式，但多数的音频文件仅支持一种音频编码。所谓编/解码器是一组算法，用来对一段特定的音频或者视频流进行编码/解码，以便音视频可以播放。之所以需要进行编码是因为，如果完全对声音或者视频进行记录则产生的数据量非常大，不利于网络数据的传输，所以需要编码。实际上编码相当于压缩音视频数据，编码后需要对数据再进行解码重组成原始的数据才能进行播放。HTML5 支持的常见的音频编解码器有AAC、MPEG-3、Ogg Vorbis等，支持的视频编解码器有H.264、VP8、Ogg Theora。表3-1显示了目前各浏览器支持音视频的情况，当然HTML5标准还未完善，以后可能会有更多的编码格式会被支持。

表3-1 各浏览器对音频和视频的支持情况

接下来了解一下多媒体元素的用法。

3.2 使用多媒体元素

两种多媒体元素使用的方式基本相似，下面以audio元素为例，audio元素使用方式如下：

<audio src=http://baidu/demo/test.mp3 controls >

您的浏览器不支持audio元素

</autio>

audio的结束标签是必需的，audio标签中的文字是起替代作用，当浏览器不支持audio元素时，audio标签中间的文字就会显示出来，controls可以用来显示播放控制器。

video使用的方式也是类似的，如下：

<video width="800" height="600" src=http://baidu/demo/test.mp4>

您的浏览器不支持video元素

</video>

除了这种使用方式外，还可以使用source作为这两种元素的子标签使用。source元素不能单独使用，必须要和audio或video结合使用，不管是audio还是video标签，其中都可以嵌套多个source标签。以audio为例，使用方式如下：

<audio>

<source src='test.mp3' type="audio/mpeg"/>

<source src='test.ogg' type="audio/ogg"/>

<source src='test.spx' type="audio/ogg"/>

</audio>

浏览器在对audio子标签source进行解析的时候，会按照source的顺序依次检测可以播放的文件，一旦成功找到，就忽略剩下的source标记。

除此之外，还可以明确指定该音频文件使用的解码器，比如：

<audio controls>

<source src="myAudio.ogg" type='audio/ogg; codec="vorbis"'>

</audio>

在source的type属性中增加了一个属性叫作"codec"，该属性可以指定具体的解码器。事实上，大多数情况下，我们无法知道或者无需知道解码器的类型，所以通常最好不用指定。

为了让用户获得更好的体验，有时候需要让我们的应用适合尽可能多的浏览器，如果浏览器不支持媒体标签，那么我们可以在媒体标签嵌入可替代的方案，比如：

<audio>

<source src='test.mp3' type="audio/mpeg"/>

<source src='test.ogg' type="audio/ogg"/>

<object type="application/x-shockwave-flash" data="player.swf?audioUrl=test.mp3

&autoPlay =true">

<param name="movie" value="player.swf?audioUrl=test.mp3&autoPlay=true">

</object>

<a href="myAudio.mp3">Download the audio file</a>

</audio>

以上代码中如果浏览器不支持audio标签，则会显示flash播放界面和超链接，这样，即使浏览器不支持audio，我们还是可以以传统的方式播放音乐。

下面来看看这些元素常用的属性和方法

3.2.1 属性

audio和video元素的属性基本相同，表3-2列举出了这两种多媒体元素的常见属性。

表3-2 audio 和video属性

续表

比如，以下代码可以获取音频对象的播放的总时间：

<audio id="a1"src="nobody.mp3"oncanplaythrough="showDuration(this)" ></audio>

<script>

function showDuration(audio)

{

alert(audio.duration);

}

</script>

oncanplaythrough是audio提供的事件，表示已经获取到足够的数据可以进行播放了。

3.2.2 方法

audio和video元素的几种常见方法相同，表3-3列举出了这两种多媒体元素的常见方法。

表3-3 audio 和video方法

3.2.3 事件

在使用audio和video中会触发多种事件，通常情况下我们会根据这些事件进行相应的处理，处理事件的方式有两种写法，一种是以addEventListener的形式，以audio为例该方式如下：

audio.addEventListener(type, listener, capture);

这种处理方式是 W3C 的标准处理事件的方式，type 表示事件类型，如 click、 keyup、mousemove。注意使用 addEventListener 绑定事件时，设置参数事件类型时不必写 on，否则会出错。listener表示处理事件的函数，capture表示事件相应顺序，如果是false表示浏览器采用事件冒泡的方式处理，true表示不采用冒泡的方式处理，通常使用false。

另外一种方式是直接通过onEventName = listener 的方式进行处理，比如单击按钮弹出对话框可以写成：

button.onclick=function(){ alert("Hello");}

表3-4列举出了这两种多媒体元素的常见事件。

表3-4 audio 和video元素事件

3.2.4 多媒体元素的限制

有些功能目前audio和video在使用中会受到一些限制，以下列出了相关限制。

（1）目前audio和video不支持流媒体格式。

（2）HTML5的媒体受到跨域资源共享限制。

（3）视频目前无法支持全屏显示。

基本上，有了以上的基础，我们可以完成一个本地播放音频的案例了。

3.3 案例：本地音乐播放器

前面的介绍中我们了解到了多媒体元素的常见的属性、方法和事件，下面的案例，实现一个简单的本地音乐播放器，图3-1显示了播放器的主界面。

播放器由几个部分组成，界面最上面是音乐控制面板，主要包含：播放按钮，实现播放盒暂停的切换；播放前一首歌曲按钮，实现播放歌曲列表中的上一首歌曲；播放下一首歌曲按钮，播放歌曲列表中下一首歌曲；音量调节按钮，调节音乐播放的音量；以及一个播放进度条，显示当前音乐播放进度。中间部分是一个简单的工具栏，包含了一个添加播放目录的按钮。最下面的是一个目录列表，用来列举出选中目录的音频文件，当双击歌曲时，播放该歌曲。

好了，接下来，我们来逐步实现播放器。

3.3.1 UI 界面设计

首先，我们创建一个 index.htm文件，完成播放器的 UI 部分，图 3-1 显示了播放器的 UI部分。

图3-1 本地音乐播放器

以下是播放器布局的主要代码：

<body>

<h2>简单的音乐播放器</h2>

<div id="container">

<!--控制面板-->

<div id="control">

<div id="title">fff&nbsp</div>

<div id="ctrlContainer">

<div id="pri"></div>

<div id="play"></div>

<div id="next"></div>

<div id="vol"></div>

</div>

<!—进度条-->

<div id="progress">

<div id="pgBar"></div>

<div id="pBar"></div>

</div>

</div>

<!--工具栏-->

<div id="toolbar">

<div id="addFolder">+</div>

</div>

<!--音频文件列表-->

<div id="fileList">

<ul id="fList"></ul>

</div>

</div>

<!--定义消息层-->

<div id="msg"></div>

</body>

以上代码使用 div 布局整个播放器界面，需要注意的是使用了一个文件选择按钮，该按钮的功能主要是用来实现选择本地文件夹，为了实现多选以及目录选择，我们需要给文件选择按钮指定multiple、webkitdirectory和directory这几个属性，其中，multiple表示可以多选文件， webkitdirectory和directory表示可以选择目录。另外定义了一个div，id是msg，用来显示用户提示信息，比如友好提示、错误提示等。

接下来，我们一步步实现播放器的每一个功能，首先，我们可以建立一个player.js文件，在文件中定义一个Player对象，该对象完成对音乐的管理、播放功能。

3.3.2 创建Player 对象

Player 对象只是提供了一个单纯的音乐管理和播放功能，暂时需要这些功能：添加音乐到列表、从列表中删除音乐、播放、停止播放等。

以下代码实现了Player的基本功能：

//定义播放对象

var Player = {

//需要创建的audio对象

audioObj:null,

//当前播放歌曲的索引号

currentId:-1,

//播放文件数组

playList:[],

//初始化player对象，创建audio对象，绑定事件

init:function()

{

//创建audio对象

var aObj = $("<audio id='ai'></audio>");

this.audioObj = aObj[0];

},

//清空播放列表

clearList:function(){

this.playList.length = 0;

this.currentId = -1;

},

//添加音乐

add:function(name, url)

{

this.playList.push({"name":name,"url":url});

},

//删除音乐

remove:function(name)

{

delete this.playList[name];

},

//按索引播放音乐

play:function(index)

{

var song=null;

if((song=this.playList[index])!=null)

{

//如果当前音乐可以播放并且和需要播放的一致，则继续播放，否则重新加载音乐

if(this.audioObj.readyState==4&&this.currentId==index)

{

this.audioObj.play();

}

else

{

this.currentId=index;

//先停止音乐

this.stop();

//重新加载

this.audioObj.src= song.url;

//绑定加载完数据后播放

$(this.audioObj).bind("canplaythrough", function(){

this.play();

})

}

}

return song;

},

//停止音乐

stop:function()

{

this.audioObj.pause();

},

//歌曲列表是否为空

isEmptyPlayList:function()

{

return this.playList.length==0;

},

//step为1，播放下一首；step为-1，播放上一首

playStep:function(step)

{

if(this.isEmptyPlayList())

{

return null;

}

//如果当前播放id为空，则播放第一首

if(this.currentId == null)

{

return this.play(this.playList[0]);

}

else

{

var id=this.currentId;

//1表示前进一首

if(step==1)

{

id=(id<this.playList.length-1)?id+1:0;

}

else if(step==-1) //-1 表示后退一首

{

id=(id>0)?id-1:this.playList.length-1;

}

return this.play(id);

}

},

//播放下一首

playNext:function()

{

return this.playStep(1);

},

//播放前一首

playPri:function()

{

return this.playStep(-1);

}

};

以上代码看出，Player 对象主要负责对音乐播放列表管理，进行音乐播放、停止的逻辑。Player对象中定义了3个属性：audioObj属性将绑定一个audio元素，使用audio元素进行音乐的操作；currentId 属性是一个整数值，指向当前播放的音乐编号，在进行播放上一曲、下一曲的时候改变 currentId 属性；playList 属性是一个对象数组，用来存放音乐列表，该数组中存放的对象格式是：{"name":name, "url":url}, name属性用来保存音乐名称，url属性用来保存产生的音乐文件的URL。

接下来看 Player 中定义的几个方法：init 方法，该方法的主要作用是做初始化操作，创建了一个audio元素，然后把Player对象audioObj指向该元素；clearList方法用于清空Player的播放列表，并把 currentId 设置为-1，表示当前播放音乐编号为空；add 方法用于添加音乐对象到playList列表中；play方法用于根据索引号播放音频文件。在play方法中需要注意的是，播放的时候首先判断当前播放的音乐和需要播放的音乐是否一致，如果一致，则直接使用 audio的play()方法播放，否则，先通过audioObj.src = song.url指定需要播放音乐的URL 地址，然后绑定audio的canplaythrough事件。canplaythrough事件表示当需要播放的音乐数据加载完成，只有当需要播放的音乐数据加载完成后我们才可以使用audio.play()方法进行播放。

stop方法用于暂停音乐播放，直接使用了audio的pause方法。其他的比如播放上一首、下一首音乐的代码主要是调用了Player的play方法。

有了Player这个灵魂对象，接下来的工作，就是把播放器的UI和Player结合起来。

3.3.3 添加本地文件

播放器界面上有一个+号的按钮，当点击该按钮时，可以添加目录中的音乐到播放器列表中，这里涉及一些问题，比如我们如何获取本地一个文件夹中的所有文件，这里，可以通过HTML5的文件组件实现，假设存在以下文件组件：

<input id="selFile" type="file" webkitdirectory directory mozdirectory >

注意，我们需要在这个文件组件中加上multiple webkitdirectory directory mozdirectory 这些属性，主要为了可以进行目录选择而不是文件选择。

一旦选择了文件夹之后，就可以通过selFile.files属性获得该目录下所有的文件列表，然后就可以通过循环这个列表把文件加入到Player的playList中。

这里还有一个小问题就是，即使可以获取文件列表，由于涉及安全问题，我们无法在Chrome中得到每一个文件真实的物理路径（IE 中可以得到），得不到真实的物理路径，那么无法通过audio的src设置路径进行播放。这里，我们可以把它转换成一个URL对象，一个URL是可以传递给audio的src属性，进行播放的，把一个文件对象转成URL可以使用以下代码：

window.createObjectURL(fObj)

在基于webkit的浏览器比如Chrome中，可能需要使用如下方式：

window.webkitURL.createObjectURL(obj)

音乐文件选择的功能代码如下：

//读取目录中文件

function selFile()

{

//获取文件组件

var fs = $("#selFile")[0].files;

if(fs.length>0)

{

$("#fList").empty();

//清空播放对象列表

Player.clearList();

//记录当前可播放文件编号

var idxs=0;

//循环数组中所有文件，把文件添加到播放列表中

for(var i=0;i<fs.length;i++)

{

var f=fs[i];

//检测是否是音乐文件

if(f.name.match(/(.mp3|.ogg|.wma|.ape)$/))

{

var li=$("<li id='"+(idxs++)+"'>"+f.name+"</li>");

//双击播放

li.bind("dblclick", function(){

playMusic($(this).attr("id"));

});

//创建文件播放url

var fURL = createURL(f);

//放到播放文件集合中

Player.add(f.name, fURL);

$("#fList").append(li);

}

}

//如果歌曲列表不为空，则设定第一首歌是当前播放歌曲

if(!Player.isEmptyPlayList())

{

Player.currentId=0;

}

}

}

上述代码中，首先通过文件对象的files属性，获取用户选择目录下的所有文件得到一个文件数组，然后循环数组，把文件添加到播放列表中，在添加文件的过程中首先判断是否是音乐文件，例子中以mp3、ogg、wma、ape格式结尾的认为是音乐文件。在循环音乐文件的过程中做了以下事情：首先创建<li>元素并添加到<ul>中，给<li>元素的 ID 进行编号，播放的时候根据这个ID进行播放；接着绑定该<li>元素的双击事件，双击的时候调用playMusic播放音乐， playMusic调用Player的play方法播放音乐；最后调用Player对象的add方法添加到列表中。

接下来，实现播放进度条功能。

3.3.4 实现播放进度条

进度条需要显示当前音乐播放的时间，并且可以通过拖动进度条来实现对当前播放音乐定位的功能，所以我们可以使用audio的currentTime属性，该属性可以读取或者设置当前音乐播放的时间点。

如图3-2所示，我们通过3个层来共同表示一个进度条，最底下白色部分表示是整个进度条的容器层，它表示进度条的总长度；圆形按钮部分表示的是当前播放音乐的时间点，可以通过拖动这个层控制播放音乐的位置；按钮的左边深色部分的层表示已经播放的部分。很明显，圆形按钮部分在整个进度条中的位置可以通过以下代码得到：

Position = (audio.currentTime/audio. Duration)*progressBar.width

图3-2 播放进度条

下面是音乐播放条的实现：

//绑定进度条事件

function bindProgress()

{

var audio=$(Player.audioObj);

//绑定进度条拖动

$(document).bind("mousedown", function(e){

var srcEle = e.srcElement;

e.preventDefault();

//如果是进度条控制按钮

if(srcEle.id == "pBar")

{

var x=e.pageX;

//获取相对父坐标的X

var pX = $("#pBar").position().left;

//获取进度条宽度

var pWidth = $("#progress").width();

//绑定mousemove移动事件

$(document).bind("mousemove", function(e){

if(!audio[0].paused)

{

e.preventDefault();

//获取鼠标偏移大小

var xOff = e.pageX-x;

var nX = (pX+xOff);

nX = (nX<=0)?0:nX;

nX = (nX>pWidth)?pWidth:nX;

//设置控制条长度

$("#pBar").css("left", nX);

//设置音乐播放时间

audio[0].currentTime = audio[0].duration*(nX/pWidth);

}

});

//绑定鼠标按起事件

$(document).bind("mouseup", function(e){

$(document).unbind("mousemove");

})

}

})

//绑定timeupdate事件，音频播放时间改变时触发

audio.bind("timeupdate", function(){

var cTime = this.currentTime, tTime = this.duration;

//设置内部滚动条长度

setCurrTime(cTime, tTime);

})

}

3.3.5 实现播放功能

由于Player类已经提供了基本的播放功能，所以只需要把播放方法和UI界面中的相关的按钮绑定即可。

以播放按钮为例，以下代码实现了播放按钮的功能：

//初始化player, 绑定播放控制按钮

function bindPlayer()

{

Player.init();

//绑定播放按钮

$("#play").click(function(){

//如果歌曲列表为空

if(Player.isEmptyPlayList())

{

showMsg("请添加歌曲！");

return;

}

//歌曲处于暂停状态

if(Player.audioObj.paused)

{

playMusic(Player.currentId);

}

else //歌曲处于播放状态

{

stop();

}

});

//绑定播放下一首按钮

$("#next").click(function(){

var song = Player.playNext();

setMusicClass(song);

});

//绑定播放上一首按钮

$("#pri").click(function(){

var song = Player.playPri();

setMusicClass(song);

})

}

bindPlayer方法中绑定了播放按钮、播放下一首和上一首歌曲的按钮事件。以play按钮为例，绑定的click方法中，首先判断歌曲列表是否为空，如果为空则提示用户，否则，判断歌曲是否处于暂停状态，如果是暂停状态则播放歌曲，如果是播放状态则暂停播放。

3.3.6 整合代码

最终，整个播放器的index.htm的完整代码如下：

<!DOCTYPE html>

<html>

<meta charset="utf-8" />

<link rel="stylesheet" href="css/song.css"/>

<body>

<h2>简单的音乐播放器</h2>

<input id="selFile" type="file" style="display:none" webkitdirectory directory

mozdirectory >

<div id="container">

<!--控制面板-->

<div id="control">

<div id="title">&nbsp</div>

<div id="ctrlContainer">

<div id="pri"></div>

<div id="play"></div>

<div id="next"></div>

<div id="vol"></div>

</div>

<div id="progress">

<div id="pgBar"></div>

<div id="pBar"></div>

</div>

</div>

<!--工具栏-->

<div id="toolbar">

<div id="addFolder">+</div>

</div>

<!--音频文件列表-->

<div id="fileList">

<ul id="fList"></ul>

</div>

</div>

<!--定义消息层-->

<div id="msg"></div>

</body>

<script src="js/jquery.js"></script>

<script src="js/player.js" charset="utf-8"></script>

<script charset="utf-8">

$(document).ready(function(){

//绑定文件按钮

$("#selFile").bind("change", selFile);

$("#addFolder").bind("click", function(){

$("#selFile").click();

})

//初始化player，绑定播放控制按钮

bindPlayer();

//绑定播放错误处理

bindPlayError();

//绑定播放进度条

bindProgress();

})

//创建对象的URL地址

function createURL(obj)

{

var url=null;

if(window.createObjectURL){

url = window.createObjectURL(obj)

}else if(window.createBlobURL){

url = window.createBlobURL(obj)

}else if(window.URL && window.URL.createObjectURL){

url = window.URL.createObjectURL(obj)

}else if(window.webkitURL && window.webkitURL.createObjectURL){

url = window.webkitURL.createObjectURL(obj)

}

return url;

}

//显示提示消息

function showMsg(msg)

{

var ww=$(document).width(),

wh = $(document).height();

var dmsg = $("#msg");

dmsg.text(msg);

dmsg.css("left", (ww-170)*0.5);

dmsg.css("top", (wh-170)*0.5)

$("#msg").fadeIn(500, function(){

$(this).fadeOut(3000);

});

}

//读取目录中文件

function selFile()

{

var fs=$("#selFile")[0].files;

if(fs.length>0)

{

$("#fList").empty();

//清空播放对象列表

Player.clearList();

//记录当前可播放文件编号

var idxs=0;

//循环数组中所有文件，把文件添加到播放列表中

for(var i=0;i<fs.length;i++)

{

var f=fs[i];

//检测是否是音乐文件

if(f.name.match(/(.mp3|.ogg|.wma|.ape)$/))

{

var li=$("<li id='"+(idxs++)+"'>"+f.name+"</li>");

//双击播放

li.bind("dblclick", function(){

playMusic($(this).attr("id"));

});

//创建文件播放url

var fURL = createURL(f);

//放到播放文件集合中

Player.add(f.name, fURL);

$("#fList").append(li);

}

}

//如果歌曲列表不为空，则设定第一首歌是当前播放歌曲

if(!Player.isEmptyPlayList())

{

Player.currentId=0;

}

}

}

//播放文件

function playMusic(index)

{

var song=Player.play(index);

setMusicClass(song);

}

//设置当前播放歌曲选中的样式

function setMusicClass(song)

{

if(song!=null)

{

//设置标题

$("#title").text(song.name);

//设置播放按钮变暂停图标

$("#play").css("background-image", "url(img/pause.png)");

//还原上次被选中的样式

$("#fList li").removeClass("selSong");

//设置选中歌曲文字样式

$("#fList li[id="+Player.currentId+"]").addClass("selSong");

}

}

//停止播放

function stop()

{

Player.stop();

//设置播放按钮变播放图标

$("#play").css("background-image", "url(img/play.png)");

}

//绑定播放处理错误事件

function bindPlayError()

{

var audio=$(Player.audioObj);

//处理错误事件

var errMsg = ["数据读取失败!", "网络故障!", "解码失败!", "不支持的格式!"];

audio.bind("error", function(){

showMsg(errMsg[this.error.code-1]);

})

}

//绑定进度条事件

function bindProgress()

{

var audio=$(Player.audioObj);

//绑定进度条拖动

$(document).bind("mousedown", function(e){

var srcEle = e.srcElement;

e.preventDefault();

//如果是进度条控制按钮

if(srcEle.id == "pBar")

{

var x=e.pageX;

//获取相对父坐标的X

var pX = $("#pBar").position().left;

//获取进度条宽度

var pWidth = $("#progress").width();

//绑定mousemove移动事件

$(document).bind("mousemove", function(e){

if(!audio[0].paused)

{

e.preventDefault();

//获取鼠标偏移大小

var xOff = e.pageX-x;

var nX = (pX+xOff);

nX = (nX<=0)?0:nX;

nX = (nX>pWidth)?pWidth:nX;

//设置控制条长度

$("#pBar").css("left", nX);

//设置音乐播放时间

audio[0].currentTime = audio[0].duration*(nX/pWidth);

}

});

//绑定鼠标按起事件

$(document).bind("mouseup", function(e){

$(document).unbind("mousemove");

})

}

})

//绑定timeupdate事件，音频播放时间改变时触发

audio.bind("timeupdate", function(){

var cTime = this.currentTime, tTime = this.duration;

//设置内部滚动条长度

setCurrTime(cTime, tTime);

})

}

//设置播放进度条，cTime表示当前音乐时间，tTime表示音乐总时间

function setCurrTime(cTime, tTime)

{

var per=(tTime<=0)?0:cTime/tTime;

var pPos = $("#progress").width()*per;

$("#pBar").css("left", pPos);

$("#pgBar").width(pPos);

}

//初始化player，绑定播放控制按钮

function bindPlayer()

{

Player.init();

//绑定播放按钮

$("#play").click(function(){

//如果歌曲列表为空

if(Player.isEmptyPlayList())

{

showMsg("请添加歌曲！");

return;

}

//歌曲处于暂停状态

if(Player.audioObj.paused)

{

playMusic(Player.currentId);

}

else //歌曲处于播放状态

{

stop();

}

});

//绑定播放下一首按钮

$("#next").click(function(){

var song = Player.playNext();

setMusicClass(song);

});

//绑定播放上一首按钮

$("#pri").click(function(){

var song = Player.playPri();

setMusicClass(song);

})

}

</script>

</html>

Index.htm使用到的play.js的完整代码如下：

//定义播放对象

var Player = {

//需要创建的audio对象

audioObj:null,

//当前播放歌曲的索引号

currentId:-1,

//播放文件数组

playList:[],

//初始化player对象，创建audio对象，绑定事件

init:function()

{

//创建audio对象

var aObj = $("<audio id='ai'></audio>");

this.audioObj = aObj[0];

},

//清空播放列表

clearList:function(){

this.playList.length = 0;

this.currentId = -1;

},

//添加音乐

add:function(name, url)

{

this.playList.push({"name":name,"url":url});

},

//删除音乐

remove:function(name)

{

delete this.playList[name];

},

//按索引播放音乐

play:function(index)

{

var song=null;

if((song=this.playList[index])!=null)

{

//如果当前音乐可以播放并且和需要播放的一致，则继续播放，否则重新加载音乐

if(this.audioObj.readyState==4&&this.currentId==index)

{

this.audioObj.play();

}

else

{

this.currentId = index;

//先停止音乐

this.stop();

//重新加载

this.audioObj.src= song.url;

//绑定加载完数据后播放

$(this.audioObj).bind("canplaythrough", function(){

this.play();

})

}

}

return song;

},

//停止音乐

stop:function()

{

this.audioObj.pause();

},

//歌曲列表是否为空

isEmptyPlayList:function()

{

return this.playList.length==0;

},

//step为1，播放下一首；step为-1，播放上一首

playStep:function(step)

{

if(this.isEmptyPlayList())

{

return null;

}

//如果当前播放id为空，则播放第一首

if(this.currentId == null)

{

return this.play(this.playList[0]);

}

else

{

var id=this.currentId;

//1表示前进一首

if(step==1)

{

id = (id<this.playList.length-1)?id+1:0;

}

else if(step==-1) //-1 表示后退一首

{

id = (id>0)?id-1:this.playList.length-1;

}

return this.play(id);

}

},

//播放下一首

playNext:function()

{

return this.playStep(1);

},

//播放前一首

playPri:function()

{

return this.playStep(-1);

}

};

到此，一个简单的本地音乐文件播放器就做好了，当然这个播放器的功能还比较简单，有待完善。虽然只是一个音乐播放器，但实际上对于视频播放也是类似的处理，audio和video元素大体上有相同的属性、方法和事件，我们在实际应用中可以根据需要进行处理。

3.4 音频处理高级技巧

我们使用audio元素完成了一个简单的本地音乐播放器，看起来不错，一切都是那么简单，只需要综合使用audio对象的方法、事件就可以实现音频的回放，我们可以直接用audio在游戏中播放背景音乐和音效，但实际上对于需要实现复杂的效果，audio元素是远远不够的。对于更为复杂的游戏或者应用来说则需要其他的解决方案，举个简单的例子，比如我们需要在播放音乐的同时，显示音乐的波形或者其他特效；另一个例子是如果需要实现类似"汤姆猫"的效果，捕获用户的语音输入后，需要对音频数据进行处理后再输出，很显然，audio元素是无法做到的。事实上audio元素，算是比较高层的API，无法处理音频数据，所以在面对音频数据处理的时候，需要更加底层的API进行处理，这就是Web Audio API所需要完成的使命。

3.4.1 Web Audio API 简介

Web Audio API是W3C规范中提供的能够处理音频数据的底层接口，提供这个接口的目的是能够满足传统游戏音频引擎的需要以及桌面应用程序的部分需求，目前，Web Audio API支持以下特性。

（1）混合多个音频。

（2）提供音频的淡入、淡出、滤波、LFOs等效果。

（3）灵活地处理多个音频通道中的数据，进行合并以及分割。

（4）能够处理来自audio以及video元素的数据。

（5）与WebRTC 相集成，接收、处理以及发送来自远程端点的 audio，实现类似网络聊天的功能。

（6）提供身临其境的各种场景音效，比如小房间/大房间、隧道、山洞、森林等。

（7）高效地实时进行时间域以及频率分析。

以上这些所有关于音频的特性，都可以通过 Web Audio API 提供的，能够使用 JavaScript语言进行高效实时处理音频的API实现。

3.4.2 Audio 上下文

首先，我们可以从AudioContex对象开始，正如canvas的所有API需要通过上下文进行绘制，Web Audio 也有属于自己的环境上下文，称之为AudioContext。AudioContext是一个能够管理声音对象的环境性上下文，在这个环境上下文中，通过一种称为"模块化路由"的方式处理音频信号。

Web Audio 提供的"模块化路由"机制能够把来自不同的音源信号通过任意数量的AudioNode对象处理后进行输出，对于AudioNode对象，我们可以认为它是一种音频处理单元或者滤波器，它可以对音频信号进行适当的处理。根据处理功能的不同，AudioNode 分为很多种类的Node，就像XML中有元素节点、属性节点、文本节点一样，AudioNode对象通常情况下包含一个输入和一个输出，有两个节点例外，一个称为"源节点"，它表示输入节点，它只有输出，表示原始的、没有经过任何 AudioNode 处理的信号，另外一个就是"目标节点"，它只有输入，表示最终的、处理完之后的信号。AudioNode有点类似Java Web的过滤器，只是Web中的过滤器用来处理请求，而AudioNode则专门用来处理音频信号。事实上，AudioNode节点处理音频数据是使用 C 编写的，我们虽然可以通过 JS 代码来获取音频数据，然后在前端通过JS 使用相关算法处理数据，但由于 JS 的效率还远不及 C 语言，大多数情况下我们直接使用AudioNode提供的处理方式即可。

AutioContext中包含了多个处理信号的节点，关于Audio Context和Audio Node之间的关系如图3-3所示。

图 3-3 给出了最简单的一种情况，在该 AudioContext 中，包含 Source 和 Destination 两个节点，正如我们前面所说的，AudioContext 可以包含任意多个 AudioNode，图 3-4 展示了包含多个节点的情况。

图3-3 AudioContext 和AudioNode

图3-4 多音频节点处理

图3-4 包含了3个Source节点，分别由3个Gain Node节点处理后，到Destination 节点结束。不管源节点和处理节点有多少，记住，Destination节点只有一个。

我们可以使用以下代码获取AudioContext对象，判断浏览器是否支持Web Audio API。

//初始化

function init()

{

try{

var aCtx = new webkitAudioContext();

}

catch(e)

{

alert("不支持音频API!");

}

}

一般情况下，可以直接使用new AudioContext()获取AudioContext，对于使用webkit 内核的浏览器中判断是否支持Audio API，需要加上webkit的前缀。

AudioContext对象具有的常见属性如表3-5所示。

表3-5 AudioContext 属性

3.4.3 加载和播放音频

获取了Audio环境上下文之后，就可以加载以及播放音频文件，为了能够加载音频文件，需要借助于XMLHttpRequest对象异步获取音频资源。使用XMLHttpRequest对象获取数据后，需要使用 decodeAudioData 方法把获取后的音频文件数据进行异步解码，解码后的数据才能够被AudioNode进行处理，decodeAudioData方法原型如下：

void decodeAudioData(ArrayBuffer audioData， DecodeSuccessCallback successCallback， optional DecodeErrorCallback errorCallback);

其中：

• ArrayBuffer：二进制音频数据，比如通过XMLHttpRequest加载后的数据。

• DecodeSuccessCallback：解码成功回调函数，该函数包含一个参数，该参数表示解码后的PCM格式的音频数据，也就是最原始的未经过任何处理的音频数据。

• DecodeErrorCallback：解码失败回调函数。

比如，可以通过以下代码加载并解码音乐数据：

//加载音乐

function load(ctx, fUrl, succFN, failFN)

{

var req = new XMLHttpRequest();

req.open('GET', fUrl, true);

//指定响应类型为arraybuffer

req.responseType = 'arraybuffer';

// 异步解码

req.onload = function() {

//解码

ctx.decodeAudioData(

this.response,

function(buf){succFN&&succFN(buf)},

function(){failFN&&failFN()}

);

}

req.send();

}

通过，decodeAudioData解码后，可以通过成功回调函数的参数获取原始音频数据，得到这个数据之后，就可以通过 AudioNode 节点处理。比如，我们可以通过 Audio Context 的createBufferSource对象创建一个AudioBufferSourceNode对象，通过该对象播放音频。

比如，结合load方法，以下函数可以直接播放音频：

//播放音频

function play(ctx, fUrl)

{

load(ctx, fUrl, function(buf){

var sNode = ctx.createBufferSource();

sNode.buffer = buf;

sNode.connect(ctx.destination);

sNode.start(0);

});

}

通过AudioContext的createBufferSource创建了一个AudioBufferSourceNode对象，在得到AudioBufferSourceNode节点后，就可以设置该节点的缓冲区对象，然后通过connect方法连接到destination节点，进行播放。

AudioBufferSourceNode是最常见的一种AudioNode对象，该对象表示的是一个音频源，常见的属性和方法分别如表3-6和表3-7所示。

表3-6 AudioBufferSourceNode属性

表3-7 AudioBufferSourceNode方法

3.4.4 调节音频增益

正如前面所说的，在Audio API 中，我们使用AudioNode 来对音频数据进行加工，假设我们需要对音频音量进行调整，可以使用GainNode进行处理。

GainNode是用于处理音频增益的节点对象，它只有一个gain属性，该属性的value属性可以描述音频增益大小，是一个浮点值，范围从0～1变化，以下示例使用了GainNode调节音频音量。

<!DOCTYPE html>

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<style type="text/css">

body, div{margin:0px;padding:0px;}

</style>

<body>

<input type="button" id="lbtn" value="播放"/>

<input type="range" id="rge" min="1" max="100" />

</div>

</body>

<script charset="utf-8" src="../js/jquery.js"></script>

<script charset="utf-8">

var aCtx, srcNode, gainNode;

$(document).ready(function(){

//初始化

init();

//绑定播放按钮

$("#lbtn").click(function(){

play(aCtx, "s2.mp3");

});

//绑定range

$("#rge").change(function(){

gainNode.gain.value = this.value*0.01;

});

});

//初始化

function init()

{

try{

aCtx = new webkitAudioContext();

//创建srcNode

srcNode = aCtx.createBufferSource();

//创建gainNode

gainNode = aCtx.createGainNode();

}

catch(e)

{

alert("不支持音频API!");

}

}

//加载音乐

function load(ctx, fUrl, succFN, failFN)

{

var req=new XMLHttpRequest();

req.open('GET', fUrl, true);

//指定响应类型为arraybuffer

req.responseType = 'arraybuffer';

// 异步解码

req.onload = function() {

ctx.decodeAudioData(

this.response,

function(buf){succFN&&succFN(buf)},

function(){failFN&&failFN()}

);

}

req.send();

}

//播放音频

function play(ctx, fUrl)

{

load(ctx, fUrl, function(buf){

srcNode.buffer = buf;

//src节点连接到gainNode

srcNode.connect(gainNode);

//gainNode节点连接到最终节点

gainNode.connect(ctx.destination);

srcNode.start(0);

});

}

</script>

</html>

上述代码中，使用了一个范围调节按钮用于修改gainNode节点的gain.value属性值，用于调节音频的增益大小，这样，音量也会相应改变。

3.4.5 混合多个音频

正如前面述说的一样，AudioContext允许混合多个音频对象，这里我们实现一个简单的效果，看看如何进行音频混合。

如图3-5所示，这里我们需要播放两种动物的声音，可以使用范围调节按钮控制每个动物声音的大小。

要实现这种效果，事实上，可以参考图 3-6 的方式进行混合。这里，我们需要创建两个Source 节点和两个GainNode 节点，分别进行处理，最后连接到Destination节点即可。

图3-5 两种动物发声

图3-6 混合两个音频

为了便于管理，我们定义两个类，一个是Aud类，表示一个音频对象，另一个是AudManager，音频管理类，用于管理所需要的所有Aud的实例对象。

Aud类实现如下：

function Aud(ctx, url)

{

this.ctx=ctx;

this.url = url;

//Source节点

this.src = ctx.createBufferSource();

//多个处理节点组

this.pNode = [];

}

Aud.prototype=

{

output:function()

{

for(var i=0;i<this.pNode.length;i++)

{

var tNode = this.src;

for(var j=0;j<this.pNode[i].length;j++)

{

tNode.connect(this.pNode[i][j]);

tNode = this.pNode[i][j];

}

tNode.connect(this.ctx.destination);

}

},

play:function(loop)

{

this.src.loop=loop||false;

this.output();

this.src.start(0);

},

stop:function()

{

this.src.stop();

},

addNode:function(node, groupIdx)

{

groupIdx=groupIdx||0;

this.pNode[groupIdx] = this.pNode[groupIdx]||[];

this.pNode[groupIdx].push(node);

}

}

//设置节点类型

Aud.NODETYPE={

GNODE:0//表示gainNode节点

}

Aud对象表示一个音频对象，里面包含常见的处理音频方法，比如播放、停止以及增加新的处理节点等，根据需要可以扩充。

AudManager音频管理类如下：

//Aud管理对象

AudManager={

urls:[],

items:[],

ctx:null,

init:function()

{

try{

this.ctx = new webkitAudioContext();

}

catch(e)

{

alert("不支持音频API!");

}

},

load:function(callback)

{

for(var i=0;i<this.urls.length;i++)

{

this.loadSingle(this.urls[i], callback);

}

},

loadSingle:function(url, callback)

{

var req=new XMLHttpRequest();

var self = this;

req.open('GET', url, true);

//指定响应类型为arraybuffer

req.responseType = 'arraybuffer';

// 异步解码

req.onload = function(){

self.ctx.decodeAudioData(

this.response,

function(buf){

var aud = new Aud(self.ctx, url);

aud.src.buffer = buf;

self.items.push(aud);

if(self.items.length==self.urls.length)

{

callback&&callback();

}

},

function(){alert("加载音频失败!");}

);

}

req.send();

},

//创建节点

createNode:function(nodeType, param)

{

var node=null;

switch(nodeType)

{

case 1:

node = this.ctx.createPanner();

break;

case 2:

node = this.ctx.createJavaScriptNode(param[0], param[1], param[2]);

break;

default:

node = this.ctx.createGainNode();

}

return node;

}

};

AudManager则提供了加载和管理所有音频实例对象的方法，可以根据需要再进行扩充。

最后，可以实现通过调节按钮控制声音了，可以在页面加载的时候使用如下方法：

$(document).ready(function(){

AudManager.urls=["cat.mp3", "frog.mp3"];

AudManager.init();

AudManager.load(function(){

var pNod1 = AudManager.createNode();

var pNod2 = AudManager.createNode();

var sound1 = AudManager.items[0];

var sound2 = AudManager.items[1];

sound1.addNode(pNod1);

sound2.addNode(pNod2);

sound1.play(true);

sound2.play(true);

//绑定range对象

$("#rge").change(function(){

AudManager.items[0].pNode[0][0].gain.value = 1-this.value*0.1;

AudManager.items[1].pNode[0][0].gain.value = this.value*0.1;

});

});

3.4.6 3D 音效

使用Audio API 可以很容易实现立体式环绕音效即3D 音效，给人一种身临其境的效果，比如在游戏中，玩家可以根据声音的来源定位音源，假设要实现如图3-7所示的效果。

图3-7 围绕中心点飞的蚊子

图3-7是一张从上向下的俯视效果图，我们模拟一只围绕中心点飞行的蚊子，蚊子会发出"嗡嗡"的声音。经过Audio API 处理后，可以明显感觉到蚊子的声音在左右两边飞行。

要实现声音的3D效果，需要使用到PannerNode节点进行处理，PannerNode节点用于3D环境音效处理，可以通过表3-8所示的几个主要方法，在3D环境中设置音源。

表3-8 Panner Node 节点常用方法

有了PannerNode节点，接下来的工作就好做了，我们使用canvas元素来显示蚊子，当然，目前只是以小圆点表示，在后面的游戏引擎开发过程中，将会使用图片资源。然后，我们设置一个时间函数，该函数中，我们需要更新蚊子的坐标实现蚊子绕中心点旋转，同时，需要使用PannerNode的setPosition方法设置音源坐标，本例中，蚊子的坐标就是音源坐标。

以下代码使用PannerNode实现了该功能。

<!DOCTYPE html>

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<style type="text/css">

body, div{margin:0px;padding:0px;text-align:center}

#cav{

border:1px solid black;

border-radius:4px;

margin:10px auto;

}

</style>

<body>

<canvas id="cav" width="320" height="200"></canvas>

</body>

<script charset="utf-8" src="../js/jquery.js"></script>

<script charset="utf-8" src="aud.js"></script>

<script charset="utf-8">

var ctx = document.getElementById("cav").getContext("2d");

//定义移动点坐标

var cX=190, cY=100, deg=0;

$(document).ready(function(){

init();

});

//画圆函数

function renderCir(x, y, r, col)

{

ctx.save();

ctx.beginPath();

ctx.arc(x, y, r, 0, Math.PI*2);

ctx.closePath();

ctx.fillStyle=col;

ctx.fill();

ctx.restore();

}

//绘制中心点

function renderCenter()

{

renderCir(160, 100, 8,"red");

}

//绘制蚊子，以圆代替，围绕中心点旋转

function renderCat()

{

renderCir(cX, cY, 8,"blue");

}

function init()

{

AudManager.urls=["skeeter.mp3"];

AudManager.init();

AudManager.load(function(){

//创建PannerNode

var pNod1 = AudManager.createNode(1);

var sound1 = AudManager.items[0];

sound1.addNode(pNod1);

sound1.play(true);

timeHandle();

});

}

//设置时钟动画

function timeHandle()

{

window.setInterval(function(){

ctx.clearRect(0, 0, 320, 200);

var rad = Math.PI*deg/180;

var sx = 90*Math.cos(rad), sy = 90*Math.sin(rad);

cX = 160+sx, cY = 100+sy;

//设置音频位置

AudManager.items[0].pNode[0][0].setPosition(sx*0.1, -sy*0.1, 0);

renderCenter();

renderCat();

deg++;

}, 30);

}

</script>

</html>

3.4.7 显示声音波形

正如前面述说的一样，Audio API还可以直接使用JS脚本对音频数据进行获取和处理，这一小节，我们获取音频数据后把音乐的波形显示出来，如同多数音频播放软件播放音乐时的效果一样。

为了能够通过JS文件处理音频数据，我们需要使用到ScriptProcessorNode节点，顾名思义，该节点是使用脚本处理的节点，可以通过AudioContext的createScriptProcessor方法创建该节点， createScriptProcessor方法的原型如下：

ScriptProcessorNode createScriptProcessor(unsigned long bufferSize，optional unsigned long numberOfInputChannels = 2， optional unsigned long numberOfOutputChannels = 2);

3个参数的含义如下。

• bufferSize：处理缓冲区的大小，值必须为256、512、1 024、2 048、4 096、8 192、16 384，该数值表示在触发audioprocess事件中需要处理的采样次数，该值设置的太大则会导致采样延迟，如果过小，则数据不是非常精确，可以根据需要设置适当的值，比如2 048。

• numberOfInputChannels：输入音源通道数量。

• numberOfOutputChannels：输出音源通道数量。

创建了ScriptProcessorNode节点后，可以在该节点的onaudioprocess事件处理函数中处理获取的采样数据，onaudioprocess的事件原型如下：

attribute EventHandler onaudioprocess;

比如，我们可以采用以下方式处理数据：

scriptNode.onaudioprocess = function(e)

{

//获取输入源

var buf = e.inputBuffer;

}

onaudioprocess 的处理函数包含一个参数，该参数是一个 AudioProcessingEvent 对象， AudioProcessingEvent对象包含以下常用的几个只读属性。

• playbackTime：当前音频回放时间。

• inputBuffer：音频输入源缓冲区，是一个AudioBuffer 对象。

• outputBuffer：音频输出源缓冲区，是一个AudioBuffer 对象。

由此我们可以得知，在 onaudioprocess 事件中获取了输入音频数据缓冲区的对象AudioBuffer，AudioBuffer常用的属性如下。

• sampleRate：PCM数据的采样率。

• length：PCM数据的长度，等同于AudioBuffer 数据区的长度。

• duration：PCM数据持续时间。

• numberOfChannels：通道数量。

方法原型：

Float32Array getChannelData(unsigned long channel);

获取指定通道的缓冲数据，返回一个Float32Array类型数组，数组中的数据为0～1的浮点小数。

好了，有了以上知识，我们就可以借助 AudioBuffer 的 getChannelData 方法获取数据，然后在canvas中进行绘制图形了。

以下是绘制音频波形的代码：

<!DOCTYPE html>

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<style type="text/css">

body, div{margin:0px;padding:0px;text-align:center}

#cav{

border:1px solid black;

border-radius:4px;

margin:10px auto;

}

</style>

<body>

<canvas id="cav" width="400" height="300"></canvas>

</body>

<script charset="utf-8" src="../js/jquery.js"></script>

<script charset="utf-8" src="aud.js"></script>

<script charset="utf-8">

var ctx = document.getElementById("cav").getContext("2d");

var SampleSize = 1024;

$(document).ready(function(){

init();

});

function init()

{

AudManager.urls=["s3.mp3"];

AudManager.init();

AudManager.load(function(){

var jNode = AudManager.createNode(2, [SampleSize, 1, 1]);

var pNode = AudManager.createNode();

ctx.strokeStyle = "red";

jNode.onaudioprocess=function(e)

{

renderWave(e);

}

var sound1 = AudManager.items[0];

sound1.addNode(pNode);

sound1.addNode(jNode, 1);

sound1.play();

});

}

function renderWave(e)

{

ctx.clearRect(0, 0, 400, 300);

var bufData = e.inputBuffer.getChannelData(0);

ctx.beginPath();

ctx.moveTo(0, 150);

for(var i=0, len=bufData.length, mix=Math.min(400, len);i<mix;i++)

{

ctx.lineTo(i, bufData[i]*150+150);

}

ctx.stroke();

ctx.closePath();

}

</script>

</html>

图3-8所示是程序运行的效果。

图3-8 显示音频波形

3.5 小结

这一章中介绍了 HTML5 中的多媒体处理部分，事实上，就目前来说，把 audio 应用到HTML5游戏中还是令人诟病的一个话题。因为，在实际应用当中，如果在游戏中应用大量的音乐、音效会出现很多如音乐突然消失、断续播放等各种问题，希望在以后的版本中这些问题能够得到较好的改善。

第4章 制作2D游戏引擎

前面我们对HTML5中的canvas做了详细的介绍，了解了canvas的基本用法，同时也完成了《你画我猜》游戏中的画板功能，总的来说有了一个不错的开始。在本章中，我们需要做的事情是，需要了解游戏基本的运行机制，同时在了解游戏运行机制的基础上建立一个基于canvas的游戏引擎。相信通过本章的学习，我们可以拥有一个属于自己的游戏引擎，同时，在这个简单游戏引擎的基础上，我们会创建一个经典的小游戏。本章需要完成的目标有以下内容：

• 理解游戏循环；

• 实现游戏引擎；

• 使用引擎创建游戏实例。

4.1 理解游戏循环

人体的生理循环系统被称之为生命之河，一点都不为过，我们从外界吸收足够的营养和能量，在人的生理循环系统作用下，这些能量和营养提供给身体的各个功能器官进行运作，最后，排除一些并不需要的杂质。正是由于日复一日、年复一年的不断的生理循环系统，我们的生命才得以延续。正如人的循环系统是生命之河一样，游戏中的主循环也是整个游戏运行的核心环节。

从单机游戏到复杂的大型多人在线游戏，游戏中的主循环部分都包含着类似的几个部分，正是有了循环中的这些游戏部分，才有了丰富多彩的游戏世界。图 4-1 流程中显示出了关于游戏运行的核心流程。

图4-1 游戏主循环

我们可以看到，实际上，游戏的运行就是整个游戏循环的运行，在整个游戏循环过程中需要完成图4-1中的主要部分。

• 初始化游戏：在整个游戏的开始部分，需要做一些游戏初始化的工作，以便游戏更加快速的运行，比如需要加载游戏运行的各种资源文件，读取游戏配置的各项数据等。

• 游戏主循环部分：

读取外部操作：整个游戏的运行离不开我们和游戏世界的实时交互，甚至来自于游戏世界本身的交互，所以游戏世界中必须监控来自外部的操作，以随时改变游戏的状态。这些外部操作主要来自外部设备或者网络数据，比如键盘、摇杆、触屏、话筒、方向盘等。

更新游戏场景：在获取了游戏外设的操作后，游戏世界必须根据这些改变游戏世界的数据进行更新整个游戏的场景。比如，在《超级玛丽》中，按下了跳跃按钮，在水果忍者中，用手指果断地画了一个十字斩等，这时候，就必须在游戏循环中根据外部操作更新玛丽的动作，让它跳得更高。事实上，这个更新的过程，并不一定需要等到外来的操作，有可能是来自内部的游戏的其他对象引发的，比如，《超级玛丽》中，玛丽不小心碰到了怪物，也有可能是来自于网络游戏的服务器，在网络游戏中，客户端的游戏世界还需要根据服务器的数据进行更新。事实上，这部分也可以看成游戏的主要逻辑处理部分，一旦满足退出游戏逻辑，那么游戏主循环就结束，结束游戏。

显示游戏场景：为了让用户有成就感，让用户有更多的视觉享受，在进行了游戏更新场景的操作以后，需要做的事情就是把游戏中的场景显示（渲染）出来，不管是战神中的血腥，还是仙剑中凄美动人的爱情，最后都需要通过画面进行渲染出来。

以上是游戏循环中的 3 个重要的部分，我们会在下面的创建基本的游戏引擎中逐步实现，现在有个一个必要的概念就足够了。

• 结束游戏循环：游戏主循环中，如果满足退出游戏逻辑，则游戏结束。

4.2 渲染引擎实现

前面对游戏主循环体有了一个大致的认识，这对于创建游戏引擎有着非常重要的作用，因为几乎所有类型的游戏引擎都会有这个主循环框架。在接下来的章节中，会逐步创建一个属于自己的游戏引擎，暂且命名为xengine吧，先让我们了解一下游戏引擎相关的概念和发展。

4.2.1 游戏引擎介绍

引擎这个概念是借助于汽车的概念，汽车中重要的部件就是发动机引擎，发动机就像是汽车的心脏一样，决定着汽车的速度、性能，当然，还有价格。游戏引擎也是一样，玩家所体验到的剧情、关卡、美工、音乐、操作等内容都是由游戏的引擎直接控制的，它扮演着发动机的角色，把游戏中的所有元素组合在一起，指导有序的工作。简单地说，引擎就是"用于控制所有游戏功能的主程序，从计算碰撞、物理系统和物体的相对位置，到接受玩家的输入，以及按照正确的音量输出声音等"。

随着游戏的规模越来越复杂化，游戏引擎已经逐渐从游戏中分离出来，有很多公司专注于游戏引擎的研发，而不去制作独立的游戏。游戏引擎这个概念也是随着游戏软件的发展逐步成熟起来，早期的时候人们开发游戏的周期比较长，一般是8～10个月，为什么这么久呢？一来是早期开发游戏的成本比较高，不论是电子游戏还是电脑游戏，由于受到硬件条件的限制，基本上开发的主要语言是C和汇编，这就对程序员的要求比较高；二是人们开发出一款游戏后，再开发新的游戏，有时候需要从头开始编写。随着人们对游戏开发领域越来越熟练，一些有经验的开发人员会逐渐把游戏中相同的部分独立出来，再开发同样类型的新游戏的时候直接重用这块独立的部分，以便节省时间和开支。根据马克思的生产力学说，单位产品的成本因生产力水平的提高而降低，自动化程度较高的手工业者最终将把那些生产力低下的手工业者淘汰出局，引擎的概念就是在这种机器化作业的背景下诞生的。

事实上，游戏引擎可以看作一个模板或者框架，它和游戏的具体实现内容无关，就比如简历表，我们可以提供一种标准的模板，大家都使用这个模板填写简历，制作简历表的人员可以无需考虑简历具体的内容，因为具体的内容需要由具体的使用人员去完成。

正如在J2EE中有许多优秀的框架（如Spring、Hibernate、Struts、JBoss Seem）一样，，游戏领域经过了多年的发展同样也有着非常多的优秀引擎框架，如Doom引擎、Unreal引擎、Quake引擎、bigword引擎、cryEngine和unity3d引擎等。这些引擎大多是商业化的游戏引擎，基于这些引擎开发的游戏也被广大游戏迷们津津乐道，如半条命、Doom、雷神之锤、孤岛危机等。

当然，随着游戏行业的分工越来越细致，游戏引擎也分得更加细化，有的引擎主要突出画面效果，让游戏的画面更加的真实，具有更强的视觉冲击力，这类引擎称之为渲染引擎。有的主要致力于更加真实的物理效果，如满足牛顿物理学的刚体加速、碰撞、爆炸等，这类引擎称为物理引擎。有的致力于研究游戏世界中各种对象的行为，比如游戏中的自动寻径，怪物猎人中的怪物的各种自动的行为等，这类引擎可以称之为AI（人工智能）引擎。

下面的部分我们将会根据以上所述的游戏主循环概念，建立一个简单的基于canvas的渲染引擎。

4.2.2 建立游戏主循环框架

理论上来讲，基本的主游戏循环可以采用以下的最简单的伪代码实现：

//Game初始化操作

Game.init()

//Game main 循环

while(true)

{

Scene.update();

Scene.render();

}

其中的Scene表示一个场景对象，Scene对象管理着所有的游戏资源，Scene.update()用于更新游戏场景，其中包含了各种的游戏逻辑处理，而Scene.render()则表示根据游戏逻辑处理完后的结果渲染游戏场景。

当然，以上的伪代码在HTML5中并不适用，事实上如果在HTML5中使用JavaScript完成这个游戏循环，估计我们能看到的是网页是死一般的寂静，因为，JavaScript执行的这个循环中无法接收任何的系统或者外部的响应，这个主循环会阻塞页面进程，除非游戏循环中断。有过Windows下开发游戏经验的都知道通常情况下，这个游戏主循环实际上是放在Windows主窗体的消息循环里面，在Windows的消息循环中执行游戏循环的主体，所以不会导致整个应用程序的阻塞。那么，通常在HTML5中如何使用JavaScript执行游戏主循环呢？答案是：使用定时器。

JavaScript中有两个常用的函数用来实现定时器功能，这两个方法分别解释如下。

• setTimeout (fn, millsec)：等待millsec 指定的时间之后，执行fn指定的方法，setTimeout就结束了。

• setInterval (fn, millsec)：间隔 millsec 指定的时间之后，执行 fn 指定的方法，直到使用clearInterval清除。

这两个函数的差别很明显，setTimeout 只会在规定的时间到达后函数 fn 执行一次，而setInterval会定时调用fn进行执行，比如，以下代码每间隔0.5秒就随机变换当前页面的背景颜色。

setInterval(function(){

var r = Math.random()*255|0,

g = Math.random()*255|0,

b = Math.random()*255|0;

c = Number((r<<16)+(g<<8)+b).toString(16);

document.body.style.backgroundColor = "#"+c;

}, 500)

关于setTimeout和setInterval需要注意的是以下几点。

（1）定时器并不是实现了多线程在后台执行，事实上 JavaScript 是单线程机制（除了使用HTML5中的Worker以外），浏览器中执行的所有代码都被放进了一个队列中顺序执行，关于浏览器对外部资源的加载、DOM的加载、DOM的渲染和事件监听等都会放入这个执行队列，定时器所做的事情只是在指定的时间点上，把需要执行的代码插入这个队列，并不保证到了时间点会立即执行，比如以下代码，我们的想法是，点击按钮后，1秒后弹出对话框：

btn.addEventListener("click", function(){

setTimeout(function(){

alert("Hello~~")

}, 1000);

//执行其他方法

for(var i=0;i<1000;i++)

{

//循环其他内容

}

})

事实上，当你点击按钮，1秒后并不一定弹出对话框，还得取决于那个for循环方法执行了多久，因为这段代码执行的队列如图4-2所示。

图4-2 浏览器执行队列

由图4-2可以看出，当点击btn的时候，浏览器会把btn的click事件方法插入到执行队列中，当执行click方法的时候，碰到了setTimeout方法，于是把定时器执行的方法插入到执行队列后，虽然设定的是1秒后执行，但这个1秒后至少是必须在btn的click执行完毕后的1秒才开始执行。

（2）使用setTimeout只会调用函数一次，如果要定时调用函数，则使用setInterval更加方便简洁，但使用setInterval函数的时候无法精确保证每次执行的时间间隔，虽然setInterval函数是表示每隔指定的时间点进行执行函数，实际上表示的是在指定的时间点上往执行队列中插入执行函数代码。如果 setInterval 本身的函数体执行时间大于函数执行的间隔时间，就会发生多个函数体好像连续执行了，出现跳帧现象，关于帧的概念下节会介绍。这种情况下，如果我们希望保证函数执行的间隔，那么我们可以使用链式setTimeout函数替代，代码如下：

setTimeout(function(){

//定时器主代码

setTimeout(arguments.callee, 100);

},

100);

（3）定时器方法中的时间参数并不是无限小，按照一般的想法，可以给定时器的执行间隔设置无限小，比如，每一毫秒执行，setInterval (fn, 1)。实际上按照HTML5给出的规范中指出，如果给的时间参数小于4，则会自动设置成4毫秒，即使是4毫秒，很多浏览器执行的时候依然达不到，一般能达到10毫秒左右，对很多程序已经足够，如果省略时间参数，则浏览器会按照最小的时间间隔执行。

有了以上的游戏主循环概念和定时器的用法之后，我们就可以把两者结合起来，形成以下的伪代码：

setInterval(游戏主体部分，游戏主体间隔时间)

其中的游戏主体部分，就是游戏主循环中的几个关键部分，包括场景的更新和渲染部分。

以下的 JavaScript 代码展示了一个毫无任何作用，但非常有意义的基本游戏框架，标志着我们迈向了走向游戏引擎的第一步。第一步总是很艰难，但让人怀念，为了纪念这个有意义的时刻，我们从这段代码开始，逐步完成一个简单的游戏引擎，暂且叫xengine，建立一个xengine的文件夹，创建一个叫xapp.js的文件，输入以下代码：

/*!

* xengine

* Copyright 2012 xiangfeng

* Released under the MIT license

* Please contact to xiangfenglf@163.com if you hava any question

* 游戏主体框架类

*/

(function(win){

var _game = win.Game = Class.extend({

//初始化方法

init:function()

{

this.paused=false;

},

//游戏主循环

mainloop:function()

{

//执行游戏主循环

},

//执行游戏

run:function(fps)

{

//设定fps默认为60帧/秒

fps = fps||60;

var self = this,

spf = (1000/fps)|0;

self.tHand = setInterval(function(){

if(!self.paused)

{

self.mainloop();

}

},spf);

},

//暂停游戏

pause:function()

{

this.paused=true;

},

//终止游戏

stop:function()

{

clearInterval(this.tHand);

}

});

}(window))

上面的代码中，定义了一个_game类，需要注意的是，该类使用了JQuery的作者提供的一个简单的，能够实现JavaScript继承的类，由于JavaScript本身没有直接提供继承的机制，该代码在原型继承的方式上做了部分修改，代码简单实用，具体代码可以参考xengine/xcore.js中的代码。该对象有3个方法，mainloop()、run()和stop()方法，mainloop方法定义了游戏的主循环体，里面应该包含场景更新和渲染的方法，本例中先什么都不做；run (fps)方法是执行游戏的mainloop主体，在run方法中使用了定时器，根据参数fps指定的时间，定时调用mainloop方法，执行游戏主体；stop方法则是停止定时器工作。基本上，一个游戏的雏形基本已经完成。

在HTML页面中可以使用以下方式启动一个Game：

<!DOCTYPE html>

<script charset='utf-8' src="xengine/xapp.js"></script>

<script>

var g = new Game();

//每秒60帧运行

g.run(60);

</script>

</html>

4.2.3 控制游戏帧数

这里，有一个概念需要了解清楚，就是FPS (Frames Per Second)，这个FPS 不是表示第一人称射击游戏，而是表示帧率，一般来说用于描述视频或游戏每秒播放多少帧，这个概念起源于电影。由于人类眼睛的特殊生理结构，如果所看画面帧率高于每秒约10～12帧的时候，就会认为是连续的，这个现象称之为视觉暂留，这也是电影实现的原理。当每秒钟播放20帧以上静止图片的时候，就形成了动画效果，在电视和电影制作行业主要有24p、25p和30p三种常见帧率。这让我联想到小时侯有一种小人书，书很小，每一页都会有同一个卡通人物，但是人物的动作有一点点区别，当快速翻动小人书的时候，里面的卡通人物就活动了起来。

在游戏世界里面也是一样，使用FPS描述游戏的流畅程度，通常如果游戏运行达到30帧以上就比较流畅了，也就是说，只要每一秒中运行游戏的主循环体30次，就能够看到比较流畅的效果，高于60帧的话，眼睛基本也感觉不出来。所以在以上的游戏框架中，run方法会接受一个fps的参数，限定游戏以多少的fps稳定运行，根据这个fps计算每一帧游戏需要执行多少毫秒，比如按30FPS执行，那游戏主体就应该按1/30秒的时间运行，如果设置成负数，则游戏将以最快的速度运行，我们也可以根据这种方式测试游戏的运行效率。需要注意的是，虽然可以设置FPS，但不保证完全精确，因为有可能每次游戏主体部分执行的时间超过所设定的时间。

为了能够实时监测到游戏运行的帧数，知道游戏能够运行的最高效率是多少，我们可以在run方法中把参数设置负数，如run (-1)，这时候，mainloop就会全速运行，然后可以在mainloop中设置一个参数来累计1秒内mainloop运行的次数，最后得到的次数就是帧数。

在前面创建的xengine目录下面，创建一个xsys.js文件，关于游戏系统的一些类可以放到这个文件中，比如描述帧状态的类 FrameState，这个类保存游戏运行过程中的一些计算出来的参数，比如最大帧数、最小帧数、平均帧数，以及上一次运行mainloop耗费的时间，这个时间对于游戏主体中的动画非常重要，以后会接触到。这个类独立于 Game 类，可以独立运行，避免和mainloop中的游戏逻辑混淆，FrameState代码如下：

/*!

* xengine

* Copyright 2012 xiangfeng

* Released under the MIT license

* Please contact to xiangfenglf@163.com if you hava any question

* 游戏系统类

*/

(function(win){

var _FState = win.FrameState ={

//最大帧数

maxFrame:0,

//最小帧数

minFrame:9999,

//即时帧数

currFrame:0,

//当前时间

currTime:0,

//每帧流逝的时间

elapseTime:0,

//用于统计每秒开始时间

_sTime:0,

//统计每秒总帧数

_sTFrame:0,

//启动帧状态检测器

start:function()

{

this.currTime=this._sTime=new Date();

},

//每帧在游戏循环前调用此方法，更新和计算帧数

update:function()

{

var fTime = new Date();

if(fTime-this._sTime>=1000)

{

//当前帧数

this.currFrame = this._sTFrame;

//计算最大帧数

this.maxFrame = (this.currFrame>this.maxFrame)?this.currFrame:this.maxFrame;

//计算最小帧数

this.minFrame = (this.currFrame<this.minFrame)?this.currFrame:this.minFrame;

this._sTFrame = 0;

this._sTime = fTime;

}

else

{

++this._sTFrame;

}

this.elapseTime = fTime-this.currTime;

this.currTime = fTime;

}

};

}(window))

这个FrameState类中记录了关于帧数的各种信息，最大帧maxFrame、最小帧minFrame、当前帧数currFrame，以及每次游戏循环执行的时间elapseTime，同时提供了两个主要的方法，一个是 start()，当游戏开始时，可调用此方法开启帧数跟踪；另一个主要的方法是 update 方法，这个方法放在游戏主循环前，实时计算游戏主体每帧运行的时间。于是，我们就可以把这个类和前面的xapp.js结合起来，在xapp.js中的run方法中增加FrameState类的调用，主要代码如下：

//执行游戏

run:function(fps)

{

//设定fps默认为60帧/秒

fps = fps||60;

var self = this,

spf = (1000/fps)|0;

//开启帧数跟踪

FrameState.start();

self.tHand = setInterval(function(){

//更新帧状态

FrameState.update();

if(!self.paused)

{

self.mainloop();

}

},spf);

};

到目前为止，我们可以控制游戏的运行速度，并监控游戏运行状态了。

4.2.4 游戏监听器

为了能够更好地监控游戏的运行状态，可以给Game类增加监听机制，比如说，我们希望在游戏运行过程当中显示游戏运行的帧数和其他的一些游戏相关信息，这个时候我们需要在游戏的主循环中嵌入一些代码。但实际上这些代码有可能根本和游戏主体没有任何关系，比如显示游戏的最大帧数，为了让这些和游戏具体逻辑无关的代码和游戏本身分离开来，可以给Game增加监听器。所谓的监听器实际上就是在游戏的主体的适当时候触发这个监听器的执行，执行的代码由具体的事件代理机制实现，比如我们希望在游戏的渲染后显示帧数，则可以用以下的伪代码：

Game. addListener(new AppEventListener({"beforeRender":function(){

//显示帧数

}}));

下面来看看具体的监听器的实现，在xengine的目录下建立一个xevent.js的类，创建以下代码：

(function(win){

var _EListener = win.EventListener = Class.extend({

init:function()

{

throw Error("This class must be inherited");

}

})

}(window))

EventListener 事件监听器是作为其他 Listener 的父类使用，所以如果我们直接使用 newEventListener()的方式则会收到一个 Error，有了这个基类以后，接下来在 xapp.js 中定义一个AppEventListener类，具体代码如下：

//简单的程序事件监听器

var _appEventListener = win.AppEventListener = EventListener.extend({

init:function(param)

{

//监听器是否生效

this.enabled = true;

this.onBeforeRender = param["beforeRender"]||this.onBeforeRender;

this.onAfterRender = param["afterRender"]||this.onAfterRender;

},

//游戏主循环执行渲染操作前触发

onBeforeRender:function(){return true;},

//游戏主循环执行渲染操作后触发

onAfterRender:function(){return true;}

});

在AppEventListener中，暂时只定义了两个主要的方法，onBeforeRender和onAfterRender，分别在游戏主体中，渲染场景之前和之后调用。有了这个AppEventListener监听器后，在Game类中必须提供方法能够添加和删除监听器，就像DOM元素的事件XX.addEventListener()一样。最终，Game类的完整代码如下所示：

var _game = win.Game = Class.extend({

//保存所有的监听器

listeners:[],

//初始化方法

init:function()

{

this.paused=false;

}

//添加监听器

addListener:function(ln)

{

this.listeners.push(ln);

},

//删除所有监听器

clearListener:function()

{

this.listeners.length=0;

},

//游戏主循环

mainloop:function()

{

//执行游戏主循环

var ltns = this.listeners;

//触发监听器渲染前事件

for(var i=0;i<ltns.length;i++)

{

ltns[i].enabled&&ltns[i].onBeforeRender();

}

//触发监听器渲染后事件

for(var i=0;i<ltns.length;i++)

{

ltns[i].enabled&&ltns[i].onAfterRender();

}

},

//执行游戏

run:function(fps)

{

//设定fps默认为60帧/秒

fps = fps||60;

var self = this, spf = (1000/fps)|0;

//开启帧数跟踪

FrameState.start();

self.tHand = setInterval(function(){

//更新帧状态

FrameState.update();

if(!self.paused)

{

self.mainloop();

}

}, spf);

},

//暂停游戏

pause:function()

{

this.paused = true;

},

//终止游戏

stop:function()

{

clearInterval(this.tHand);

}

});

当然，需要注意的是，在mainloop中没有任何的游戏更新和渲染代码，在下面的章节中会逐步完善xengine。到此为止，我们可以建立一个简单的demo程序，让它尽情地飞奔，并显示游戏的帧数，创建demo.html代码如下：

<!DOCTYPE html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<body style="text-align:center">

最大帧数:<span id="maxf"></span><br>

最小帧数:<span id="minf"></span><br>

当前帧数:<span id="currf"></span><br>

</body>

<script src="js/jquery.js"></script>

<script charset='utf-8' src="xengine/xcore.js"></script>

<script charset='utf-8' src="xengine/xcore.js"></script>

<script charset='utf-8' src="xengine/xevent.js"></script>

<script charset='utf-8' src="xengine/xsys.js"></script>

<script charset='utf-8' src="xengine/xapp.js"></script>

<script>

var mx = $("#maxf"), mi = $("#minf"), cr = $("#currf");

var g = new Game();

//添加监听器

g.addListener(new AppEventListener({

"afterRender":function(){

//显示帧信息

mx.text(FrameState.maxFrame);

mi.text(FrameState.minFrame);

cr.text(FrameState.currFrame);

}

}));

//开始飞奔

g.run(-1);

</script>

</html>

虽然，目前该代码运行的时候只是显示帧数，没有任何的显示效果，但万丈高楼平地起，这对于以后建立一个良好的游戏平台打下了一个坚实的基础。

4.2.5 创建游戏主窗口

前面已经搭建起了一个主体框架，并且能够控制游戏运行的速度，监听游戏运行主体循环，但还没有任何可以显示的界面。现在我们就来实现显示游戏的主窗体，说到用于显示游戏的窗口，这个重要的任务就交给canvas来实现了。在第2章中，我们学习了关于canvas的种种知识，相信已经为我们做好下一步的工作打下了良好的基础。

很多情况下，我们可以借鉴戏剧的概念，其实，游戏就是一场戏，戏剧中，可能会把整个戏剧分成几个场景，如序幕、正篇、过场、结局等。事实上，一部游戏也可以看成是由不同的场景在不同的时候上映，只不过场景的主角可能不同而已。借助于这个概念，我们可以把游戏的窗口，看成是不同场景的舞台，舞台的主角就是游戏中各种各样的角色，这些角色称为精灵。后面的小节中我们会专门介绍精灵，现在要做的，是给这些精灵搭建表演的舞台。图4-3展示了游戏和其场景之间的关系，一个游戏可以由多个场景元素组成；图4-4则展示了游戏场景一中的各种精灵，也就是游戏的主角，事实上大多数简单的游戏基本只有一个场景。

图4-3 游戏场景

图4-4 游戏场景一中的精灵

接下来的工作，我们根据这些关系可以得到图4-5所示的类图结构，该图使用UML（Unified Model Language，统一建模语言）表达，有兴趣的读者可参考UML相关的书籍。

图4-5 游戏场景类图

Game类，是在4.2.2节中创建的类。SceneManger类，顾名思义，专门用于管理游戏场景的类，它可以包含一个或者多个游戏场景类 Scene，类似于管理员的工作，主要负责游戏场景的创建、切换以及销毁工作，我们能看到的游戏窗体就会在这个类中得到体现。Scene类，游戏场景类，这个类主要负责游戏中的渲染对象RenderObj及其子类的创建工作。这里没看到Sprite，也就是精灵类，后面再进行详细介绍。一个Scene类中同样可以包含0个或者多个RenderObj对象，RenderObj 对象，是用于进行渲染的对象，最终屏幕上能够显示的所有的物体基本都是这个类以及它的子类。好了，接下来看看Scene和SceneManager的具体实现。

游戏场景类Scene的代码如下：

/*!

* xengine

* Copyright 2012 xiangfeng

* Released under the MIT license

* Please contact to xiangfenglf@163.com if you hava any question

* 游戏场景类

*/

(function(win){

//场景类

var _scene = win.Scene = Class.extend({

init:function(arg)

{

arg=arg||{};

//场景名称

this.name = arg.name||("Unnamed_"+(++_scene.SID));

//位置信息

this.x = arg.x||0;

this.y = arg.y||0;

this.w = arg.w||320;

this.h = arg.h||200;

this.color = arg.color||"black";

//场景容器

this.holder = $("<div id='sc_"+this.name+"' style='position:absolute;overflow:

hidden; left:0px;top:0px'></div>")

//绑定的canvas元素，以后的精灵都在这个canvas上进行绘制

this.cvs = $("<canvas id='cv_"+this.name+"' style='z-index:-1;position:absolute;

left:0px;top:0px'> </canvas>");

this.ctx = this.cvs[0].getContext("2d");

this.setPos();

this.setSize();

this.setColor(this.color);

this.holder.append(this.cvs);

$(document.body).append(this.holder);

},

//设置位置

setPos:function(x, y)

{

this.x=x||this.x;

this.y = y||this.y;

this. holder.css("left", this.x).css("top", this.y);

},

//设置大小

setSize:function(w, h)

{

this.w=w||this.w;

this.h = h||this.h;

this.holder.css("width", this.w).css("height", this.h);

this.cvs.attr("width", this.w).attr("height", this.h);

},

//设置背景

setColor:function(color)

{

this.color=color||"black";

this. holder.css("background-color", this.color);

},

//更新场景

update:function()

{

//更新所有精灵

},

//执行渲染

render:function()

{

//渲染所有精灵

},

//清除背景

clear:function()

{

this.ctx.clearRect(0, 0, this.w, this.h);

},

//显示

show:function()

{

this.holder.show();

},

//隐藏

hide:function()

{

this.holder.hide();

},

fadeOut:function(time, fn)

{

this.holder.fadeOut(time, fn);

},

fadeIn:function(time, fn)

{

this.holder.fadeIn(time, fn);

},

//设置背景, pattern:0(居中), 1(拉伸), 默认(平铺)

setBGImg:function(imgURL, pattern)

{

this.holder.css("background-image","url("+imgURL+")");

switch(pattern)

{

case 0:

this.holder.css("background-repeat", "no-repeat");

this.holder.css("background-position", "center");

break;

case 1:

this.holder.css("background-size", this.w+"px "+this.h+"px ");

break;

}

},

//清除相关所有资源

clean:function()

{

this.cvs.remove();

this.holder.remove();

this.cvs = this.holder = this.ctx = null;

}

});

//记录scene编号

_scene.SID = 0;

//注册类到类工厂中

ClassFactory.regClass("Scene", _scene);

}(window))

Scene主要包含一个div以及一个canvas，div作为整个Scene的容器，而canvas则是作为整个场景显示使用。通常情况下，把游戏显示的场景放在div容器中的最底层，而div容器还可以放置其他的对象，比如游戏UI，游戏的UI则通常在整个游戏的顶层。

在Scene的构造方法init定义了Scene的外观属性，比如位置、大小、背景颜色等，还会创建一个canvas对象，用于渲染显示游戏场景中的所有元素，也就是精灵对象。关于精灵对象和使用Scene对象来管理精灵对象的方法在下节中详细阐述。

然后定义了几个最常用的方法，比如设置场景大小、位置、显示隐藏以及背景颜色和背景图像等，同时，为了能够管理游戏场景中所有物体的更新和渲染操作，还提供了update()和render()方法，用于更新和渲染整个游戏场景。

最后，有一个需要特别注意的地方，就是ClassFactory.regClass ("Scene", _scene)方法，该方法用于注册一个新的类到类工厂中，第一个参数是注册的类名，第二个参数就是指向该类的构造函数。注册了之后，就可以通过ClassFactory. newInstance (className, args)的方式来实例化一个在类工厂中已经注册的类，第一个参数是注册的类名，第二个参数是该类实例化时候需要的参数。需要注意的是这个参数必须要以数组的形式传递，比如：通过new Class (a, b, c)的方式需要改成newInstance ("Class", [a, b, c])。目前，我们可以把它看成是一个工厂方法的实现，工厂方法是常见设计模式中的一种，有兴趣的读者可以参考关于设计模式相关的书籍。ClassFactory方法在xcore.js中定义。最后，我们可以通过以下两种方式创建一个宽度为640，高度为480，红色背景的场景。

方式一：

var scene = new Scene({"w":640, "h":480, "color":"red"});

方式二：

var scene = ClassFactory.newInstance("Scene", [{"w":640, "h":480, "color":"red"}]);为了便于统一的管理，事实上，scene的创建方式由SceneManager去负责，因为SceneManager就是场景管理类，由它去管理场景的创建、销毁以及切换是比较合适的。

游戏场景管理类SceneManager的代码如下：

/*!

* xengine

* Copyright 2012 xiangfeng

* Released under the MIT license

* Please contact to xiangfenglf@163.com if you hava any question

* 游戏场景管理类

*/

(function(win){

(function(win){

//场景管理类

var _sceneman = win.SceneManager = Class.extend({

init:function(param)

{

//以命名方式保存，便于快速通过名称获取

this.namedScenes = {};

//以堆栈方式保存所有场景，最后的元素为栈顶

this.scenes = [];

},

//创建新的场景，通过类名和参数创建，因为scene可能有自己的子类，需要注意的是arg必须是数组形式

createScene:function(sceneClass, args){

var sc = null;

if(arguments.length == 1)

{

sc=ClassFactory.newInstance("Scene", arguments[0]);

}

else{

sceneClass = sceneClass||"Scene";

sc = ClassFactory.newInstance(sceneClass, args);

}

this.push(sc);

return sc;

},

//场景重排序

sortSceneIdx:function()

{

for(var i=0, len=this.scenes.length;i<len;i++)

{

var sc=this.scenes[i];

sc.holder.css("z-index", i);

}

},

//压入scene场景

push:function(scene)

{

if(!this.getScene(scene.name))

{

this.scenes.push(scene);

this.namedScenes[scene.name] = scene;

this.sortSceneIdx();

}

},

//移除顶部场景

pop:function()

{

var sc = this.scenes.pop();

if(sc!=null)

{

sc.clean();

delete this.namedScenes[sc.name];

this.sortSceneIdx();

}

},

//删除场景

remove:function(name)

{

var sc=this.getScene(name);

if(sc!=null)

{

sc.clean();

delete this.namedScenes[name];

ArrayUtil.removeFn(this.scenes, function(s){

return s.name===name;

})

this.sortSceneIdx();

}

},

//交换场景位置

swap:function(from, to)

{

if(from>=0&&from<=this.scenes.length-1

&&to>=0&&to<=this.scenes.length-1)

{

var sc=this.scenes[from];

this.scenes[from] = this.scenes[to];

this.scenes[to] = sc;

this.sortSceneIdx();

}

},

//获取某个场景的索引

getIdx:function(scene)

{

return scene.holder.css("z-index");

},

//把某个场景移动到最顶部

bringToTop:function(scene)

{

var idx=this.getIdx(scene);

if(idx!=this.scenes.length-1)

{

this.scenes.splice(idx, 1);

this.scenes[this.scenes.length] = scene;

this.sortSceneIdx();

}

},

//把某个场景移动到最底部

bringToLast:function(scene)

{

var idx=this.getIdx(scene);

if(idx!=0)

{

this.scenes.splice(idx, 1);

this.scenes.splice(0, 0, scene);

this.sortSceneIdx();

}

},

//场景后移

back:function(scene)

{

var idx=this.getIdx(scene);

if(idx>0)

{

this.swap(idx, idx-1);

}

},

//场景前移

forward:function(scene)

{

var idx=this.getIdx(scene);

if(idx<this.scenes.length)

{

this.swap(idx, idx+1);

}

},

//根据名称获取场景

getScene:function(name)

{

return this.namedScenes[name];

},

//获取当前场景, 顶部场景为当前场景

getCurrentScene:function()

{

return this.scenes[this.scenes.length-1];

},

//清除所有场景

clearAll:function()

{

for(var i in this.scenes)

{

this.scenes[i].clean();

}

this.namedScenes = {};

this.scenes = [];

}

});

}(window))

场景管理类主要负责游戏场景的创建、移除、切换等功能，为了能够管理所有的场景对象，其中包含了一个scenes属性，用于保存所有的场景实例。目前提供的几个基本的功能，主要用于场景的创建以及删除、获取功能，以后的小节中会逐渐完善。到目前为止基本可以让游戏的舞台搭建成功了，为了让这个舞台能够显示出来，我们可以创建一个html网页测试已经完成的引擎。基本上，引擎的代码都放在xengine目录中，主要为了和具体的游戏独立开，在xenging目录外面创建一个demo2.html网页，显示一个超级玛丽背景的游戏窗口，代码如下：

<!DOCTYPE html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<body>

</body>

<script src="js/jquery.js"></script>

<script charset='utf-8' src="xengine/xcore.js"></script>

<script charset='utf-8' src="xengine/xevent.js"></script>

<script charset='utf-8' src="xengine/xsys.js"></script>

<script charset='utf-8' src="xengine/xscene.js"></script>

<script charset='utf-8' src="xengine/xscenemanager.js"></script>

<script charset='utf-8' src="xengine/xapp.js"></script>

<script>

var g = new Game();

//创建游戏场景

var sc = g.sceneManager.createScene([{"name":"title", "x":30, "y":10}]);

//设置场景背景

sc.setBGImg("images/bg.png", 1);

//开始飞奔

g.run(-1);

</script>

</html>

图4-6所示是demo2.html的运行界面。

图4-6 demo2 运行界面

有了游戏场景之后，剩下的就差主角了，接下来的小节中，我们将会完成游戏主角的部分。

4.2.6 创建游戏精灵

游戏精灵，通常在游戏中有一个专门的术语，叫作 Sprite，基本上在游戏中出现的各种元素都可以被称之为精灵，广义上来说，如游戏中的背景、人物、NPC、怪物、宝物、障碍，甚至菜单，都是精灵。狭义上的精灵一般指的是游戏中可以活动的物体，如主角、NPC等。精灵可以说是游戏舞台的角色，正是有了舞台、角色、音乐、音效、剧本等，最终构成了丰富多彩的游戏世界。

在xengine中，所有的精灵对象都被设计成RenderObj对象的子类。RenderObj称为渲染对象，提供了在游戏世界中，能够被引擎进行渲染的最基本的特性，如名称、大小、位置、角度等，这个对象将作为精灵对象的父类出现，为了让RenderObj具有继承性，RenderObj也从Class类继承。下面来看看具体RenderObj的实现：

/*!

* xengine

* Copyright 2012 xiangfeng

* Released under the MIT license

* Please contact to xiangfenglf@163.com if you hava any question

* 游戏渲染对象类

*/

(function(win){

//渲染对象类

var _renderObj = win.RenderObj = Class.extend({

init:function(name)

{

this.name=name||("Unnamed_"+(_renderObj.SID++));

//拥有者，指向场景对象

this.owner = null;

//x 和 y 方向坐标

this.x = 0;

this.y = 0;

//对象宽度和高度

this.w = 0;

this.h = 0;

//x和y方向的速度

this.dx = 0;

this.dy = 0;

//x, y方向的加速度

this.vx = 0;

this.vy = 0;

//角度

this.deg = 0;

//z-index

this.zIdx = 0;

//是否可见

this.isVisible = true;

//是否可移除

this.canRemove = false;

},

//设置位置

moveTo:function(x, y)

{

this.x=x||this.x;

this.y = y||this.y;

},

//移动

move:function(xOff, yOff)

{

this.x+=xOff;

this.y +=yOff;

},

//移动一小步

moveStep:function()

{

this.dx+=this.vx;

this.dy += this.vy;

this.x += this.dx;

this.y += this.dy;

},

//旋转deg度

rotate:function(deg)

{

this.deg=deg;

},

//更新方法，每一帧调用

update:function()

{

this.moveStep();

},

//渲染方法，每一帧调用, ctx 是canvas 环境

render:function(ctx)

{

return;

}

});

//记录renderObj编号

_renderObj.SID = 0;

_renderObj.ClassName = "RenderObj";

//注册类到类工厂中

ClassFactory.regClass(_renderObj.ClassName, _renderObj);

}(window))

在表4-1中列出了RenderObj的常见属性。

表4-1 RenderObj的常见属性

对于一个可以用于显示的对象而言，以上属性已经基本足够，其中需要注意的是渲染对象的位置属性(x, y)和其速度属性。从运动学角度而言，对于二维的图像运动来说，任何一个物体的复杂运动都可以分解成x（水平）方向和y（垂直）方向的运动，如图4-7所示，所以有了dx和dy这两个分量属性，就可以描述物体运动的速度。同样道理， vx和vy分别表示物体在水平方向和垂直方向的物体速度的增量，也就是加速度，如果是匀速运动，这两个分量则是0。

图4-7 物体速度可分解为vx 和vy

RenderObj提供的方法中，最重要的两个方法是update和render方法。update方法用于更新物体的状态信息，比如速度、方向等，当然，这个需要根据不同的游戏逻辑进行处理，例如飞机游戏中子弹轨迹的更新，玩家控制飞机改变方向等。render 方法就是渲染方法了，表示渲染也就是绘制该物体，由于 RenderObj 是一个比较抽象的顶层类，所以该方法没有提供具体的实现，具体绘制什么样的图形，得有具体的实现类去完成。接下来的弹球小例子中，我们就可以看到一个简单的 Ball的实现类。

有了RenderObj类之后，还需要把这些对象用Scene场景管理起来，回顾图4-5所示的类图结构，需要在Scene场景类中添加关于RenderObj的相关代码，用Scene管理所有的RenderObj对象，最终Scene的代码如下：

/*!

* xengine

* Copyright 2012 xiangfeng

* Released under the MIT license

* Please contact to xiangfenglf@163.com if you hava any question

* 游戏场景类

*/

(function(win){

//场景类

var _scene = win.Scene = Class.extend({

init:function(arg)

{

arg=arg||{};

//场景名称

this.name = arg.name||("Unnamed_"+(++_scene.SID));

//位置信息

this.x = arg.x||0;

this.y = arg.y||0;

this.w = arg.w||320;

this.h = arg.h||200;

this.color = arg.color||"black";

//场景容器

this.holder = $("<div id='sc_"+this.name+"' style='position:absolute;

overflow:hidden;left:0px;top:0px'></div>")

//绑定的canvas 元素, 以后的精灵都在这个canvas 上进行绘制

this.cvs = $("<canvas id='cv_"+this.name+"' style='z-index:-1;position:

absolute; left:0px;top:0px'></canvas>");

this.ctx = this.cvs[0].getContext("2d");

this.setPos();

this.setSize();

this.setColor(this.color);

this.holder.append(this.cvs);

$(document.body).append(this.holder);

//保存所有的监听器

this.listeners = [],

//记录所有的渲染对象

this.rObjs = [];

//命名的渲染对象，便于根据名称快速查找对象

this.namedRObjs = {};

},

//创建渲染对象

createRObj:function(className, arg)

{

className=className||"RenderObj";

var obj = ClassFactory.newInstance(className, arg);

this.addRObj(obj);

return obj;

},

//添加到rObjs中

addRObj:function(renderObj)

{

renderObj.owner=this;

this.rObjs.push(renderObj);

this.namedRObjs[renderObj.name] = renderObj;

},

//删除对象

removeRObj:function(renderObj)

{

this.removeRObjByName(renderObj.name);

},

//根据名称设置对象移除标记

removeRObjByName:function(name)

{

this.namedRObjs[name]&&(this.namedRObjs[name].canRemove=true||true);

},

//移除所有置可移除标记的对象

removeAllCanRemove:function()

{

for(var i=0;i<this.rObjs.length;i++)

{

var o=this.rObjs[i];

if(o.canRemove){

delete this.namedRObjs[o.name];

this.rObjs.splice(i, 1);

}

}

},

//根据名称查找对象

getRObjByName:function(name)

{

return this.namedRObjs[name];

},

//清除所有渲染对象

clearRObj:function()

{

this.rObjs=[];

this.namedRObjs = {};

},

//添加监听器

addListener:function(ln)

{

this.listeners.push(ln);

},

//清空监听器列表

clearListener:function()

{

this.listeners.length=0;

},

//更新场景

update:function()

{

for(var i=0;i<this.rObjs.length;i++)

{

this.rObjs[i].update();

}

this.removeAllCanRemove();

},

//执行渲染

render:function()

{

var ltns=this.listeners;

//先清除场景，再渲染

this.clear();

//执行渲染前监听器

for(var i=0, len = ltns.length;i<len;i++)

{

ltns[i].enabled&&ltns[i].onBeforeRender(this);

}

this.renderRObj();

//执行渲染后监听器

for(var i=0;i<len;i++)

{

ltns[i].enabled&&ltns[i].onAfterRender(this);

}

},

//渲染所有对象

renderRObj:function()

{

for(var i=0, len=this.rObjs.length;i<len;i++)

{

this.ctx.save();

this.rObjs[i].isVisible&&this.rObjs[i].render(this.ctx);

this.ctx.restore();

}

},

//设置位置

setPos:function(x, y)

{

this.x=x||this.x;

this.y = y||this.y;

this.holder.css("left", this.x).css("top", this.y);

},

//设置大小

setSize:function(w, h)

{

this.w=w||this.w;

this.h = h||this.h;

this.holder.css("width", this.w).css("height", this.h);

this.cvs.attr("width", this.w).attr("height", this.h);

},

//设置canvas背景

setColor:function(color)

{

this.color=color||"black";

this.cvs.css("background-color", this.color);

},

//清除canvas背景

clear:function()

{

this.ctx.clearRect(0, 0, this.w, this.h);

},

//显示

show:function()

{

this.holder.show();

},

//隐藏

hide:function()

{

this.holder.hide();

},

fadeOut:function(time, fn)

{

this.holder.fadeOut(time, fn);

},

fadeIn:function(time, fn)

{

this.holder.fadeIn(time, fn);

},

//设置背景, pattern:0(居中), 1(拉伸), 默认(平铺)

setBGImg:function(imgURL, pattern)

{

this.holder.css("background-image", "url("+imgURL+")");

switch(pattern)

{

case 0:

this.holder.css("background-repeat", "no-repeat");

this.holder.css("background-position", "center");

break;

case 1:

this.holder.css("background-size", this.w+"px "+this.h+"px ");

break;

}

},

//清除相关所有资源

clean:function()

{

this.listeners=null;

this.cvs.remove();

this.holder.remove();

this.cvs = this.holder = this.ctx = null;

}

});

//记录scene编号

_scene.SID = 0;

_scene.ClassName = "Scene";

//注册类到类工厂中

ClassFactory.regClass(_scene.ClassName, _scene);

}(window))

相对于之前的Scene类，增加了两个集合对象rObjs和namedRObjs对象，这两个对象用于保存在本场景中所有的对象。之所以用两个集合保存，rObjs用数组，主要方便遍历所有的渲染对象，namedRObjs使用类似于Map 的集合是为了快速通过名称获取对象，当然遍历的时候也可通过 namedRObjs，但需要通过 for...in 的语法，降低循环的效率。除了这两个集合对象用于保存对象以外，增加了关于对渲染对象管理的方法，比如创建、增加、移出以及根据名称查询的方法，同时也更新了scene中的update以及render方法。在update和render方法中，我们会遍历该场景中所有的渲染对象，执行渲染对象的update以及render方法。

在增加了RenderObj类，修改了Scene类之后，最后需要对Game类的游戏主循环做一些调整，之前是没有任何的渲染方法，现在必须要加上了，修改后的Game类中的mainloop方法如下：

//游戏主循环

mainloop:function()

{

//执行游戏主循环

var ltns = this.listeners;

//触发监听器渲染前事件

for(var i=0;i<ltns.length;i++)

{

ltns[i].enabled&&ltns[i].onBeforeRender();

}

//获取当前场景，更新，并渲染

var scene = this.sceneManager.getCurrentScene();

if(scene)

{

scene.update();

scene.render();

}

//触发监听器渲染后事件

for(var i=0;i<ltns.length;i++)

{

ltns[i].enabled&&ltns[i].onAfterRender();

}

}

在游戏主循环中，渲染前后的监听器之间加上了对场景的渲染，代码比较简单，就是通过场景管理器获取当前需要渲染的场景，然后进行场景的更新以及渲染。

最后，我们可以通过图4-8，用UML描述的序列图来看看目前已经完成的xengine部分的主体流程，大体上分为以下几个步骤。

（1）进入游戏主循环。

（2）Game类通过场景管理器获取当前游戏场景。

（3）Scene对象更新当前的游戏场景。

（3.1）循环更新当前场景中所有的RenderObj对象。

（3.2）RenderObj对象执行更新方法。

图4-8 xenging游戏主体序列图

（4）Scene对象执行场景渲染。

（4.1）清除当前场景。

（4.2）循环渲染场景中所有RenderObj对象。

（4.3）RenderObj对象执行渲染方法。

（5）返回。

引擎部分已经基本完成，但引擎并不负责一个游戏的主要逻辑部分，引擎应该独立于任何游戏逻辑而存在，在接下来的小节中，可以通过一个简单的小例子，来看看 xenging 是如何应用到游戏中去的。

4.2.7 案例：屏幕弹球

这个例子是一个简单的屏幕弹球，游戏开始的时候，会产生一定数量的小球，在屏幕中跳动，当碰到屏幕边框的时候会反弹，图 4-9 是运行时的一个截图。程序虽然简单，但可以帮组我们理解引擎的使用。

图4-9 屏幕弹球

接下来，来看看具体的实现。为了让游戏独立于引擎，通常情况下引擎相关的所有文件放在一个独立目录，其他的和游戏本身逻辑相关的源文件可以放在其他独立的文件夹中。

本例中，需要创建一个ball.js文件和game.js文件。ball.js文件用于描述Ball类，game.js文件是弹球游戏主文件。在创建游戏精灵小节中，提及 RenderObj 是所有可渲染对象的父类，并且，它没有提供任何渲染对象的方法，而是交给具体的子类去实现。先看看ball.js是如何描述Ball类的。

/*

* Ball 弹球类

*/

(function(){

//从引擎的RenderObj继承

Ball = RenderObj.extend({

init:function(name, r)

{

//定义半径

this.r = r||10;

this.color = "white";

this._super(name);

},

//重写update

update:function()

{

var w=this.owner.w,

h = this.owner.h;

//到达边界改变速度方向

if(this.x<this.r||this.x>w-this.r)

{

this.dx=-this.dx;

};

if(this.y<this.r||this.y>h-this.r)

{

this.dy=-this.dy;

}

//调用父类方法

this._super();

},

//重写render方法

render:function(ctx)

{

//画球填充中心

ctx.beginPath();

ctx.fillStyle = this.color;

ctx.arc(this.x, this.y, this.r-3, 0, Math.PI*2);

ctx.fill();

ctx.lineWidth = 2;

//秒边

ctx.beginPath();

ctx.strokeStyle ="white";

ctx.arc(this.x, this.y, this.r, 0, Math.PI*2);

ctx.stroke();

}

});

Ball.ClassName = "Ball";

//注册Ball类

ClassFactory.regClass(Ball.ClassName, Ball);

}())

Ball类重写了父类的两个主要方法，分别是update、render。update方法是用于更新渲染对象的状态信息，如方位、速度及其他与游戏逻辑有关的信息。在本例中，小球如果碰到场景的边缘则会反弹，根据前面描述的，速度可以分解成x和y方向，反弹的时候实际上只需要修改速度的方向就可以了，在水平方向检测到如果碰到边缘则x= −x，如果是垂直方向则y= −y。render方法主要是用于渲染 ball，在本例中则是画出了以（x，y）为中心，r 为半径的圆，有了 Ball对象后，看看game.js文件是如何使用该渲染对象的。

/*

* ball 游戏

*/

(function(){

var g = new Game();

//创建游戏场景

function initGame()

{

//获取场景管理器

var scm = Game.sceneManager;

//创建场景

var sc = scm.createScene([{"w":400, "h":300}]);

initRenderObj(sc);

}

//创建游戏精灵

function initRenderObj(sc)

{

//随机创建20个小球

for(var i = 0;i<20;i++)

{

var obj=sc.createRObj(Ball.ClassName);

//设置随机位置

obj.moveTo(MathUtil.randInt(20, 380), MathUtil.randInt(20, 280));

//设置随机速度0~3

obj.dx = MathUtil.randInt(1, 3);

obj.dy = MathUtil.randInt(1, 3);

//设置随机颜色

obj.color = ColorUtil.rgb(MathUtil.randInt(255), MathUtil.randInt(255),

MathUtil.randInt(255));

}

}

//初始游戏

initGame();

//开始飞奔

g.run(-1);

}())

在game.js文件中，运行游戏Gam.run之前做了一些初始化的工作，这些工作为游戏的正常运行提供了前提。基本上在使用 xengine 的时候，至少需要两个步骤，第一，创建一个游戏场景，第二，在游戏场景中添加游戏主角，在本例中的游戏主角就是 Ball 的实例。所以，在initGame中，先通过SceneManager对象的createScene创建了一个场景对象，接着设置该场景为当前使用场景，最后调用initRenderObj方法创建所有的ball对象。在initRenderObj方法中，使用scene的createRObj方法创建了20个Ball对象，然后随机设置Ball的方向、速度和颜色，一切准备好之后，就可以调用Game.run方法让游戏飞奔了。一切都准备好了之后，我们只需要创建一个html文件，导入所有的引擎文件和游戏文件就可以了，创建demo3.html，代码如下：

<!DOCTYPE html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<body>

</body>

<script src="js/jquery.js"></script>

<!--导入引擎js部分-->

<script charset='utf-8' src="xengine/xcore.js"></script>

<script charset='utf-8' src="xengine/xutil.js"></script>

<script charset='utf-8' src="xengine/xevent.js"></script>

<script charset='utf-8' src="xengine/xsys.js"></script>

<script charset='utf-8' src="xengine/xrenderobj.js"></script>

<script charset='utf-8' src="xengine/xscene.js"></script>

<script charset='utf-8' src="xengine/xscenemanager.js"></script>

<script charset='utf-8' src="xengine/xapp.js"></script>

<!--游戏js部分-->

<script charset='utf-8' src="js/ball.js"></script>

<script charset='utf-8' src="js/game.js"></script>

</html>

demo3.html文件比较简单，先导入引擎的js部分，需要注意的是，由于引擎文件的类的关联性，所以需要注意导入的顺序，然后再导入游戏的js文件部分即可。

在本节中，我们使用canvas的图形函数arc绘制精灵，实际上大多数情况下，游戏精灵绘制大多都是通过图像来完成，接下来，我们在xengine中加入游戏精灵的帧动画实现。

4.2.8 游戏精灵帧动画

上一小节完成了RenderObj类的制作，但事实上RenderObj是所有其他可渲染对象的父类，没有任何的渲染方法的实现，具体的渲染实现必须要使用子类去实现。这一小节，我们就完成带有精灵帧动画的子类Sprite。

在游戏中，精灵的动作，如行走、攻击、魔法、特效等，都是依靠动画来表现的，游戏中常用的动画方式有很多种，如逐帧动画、关键帧动画、顶点动画、蒙皮动画等，关键帧动画、顶点动画和蒙皮动画主要应用在3D游戏领域，而逐帧动画是2D游戏中最主要的方式，通常也称为定格动画。其原理是把一个精灵的动作按时间分解，在时间轴的每帧上逐帧绘制不同的内容，每帧只改动少量的部分，这样，连续播放就形成了动画。

逐帧动画由于很类似电影的播放效果，而且可以对播放的每一帧做细节上的调整，所以，非常适合于表演很细腻的动画，但缺点也是明显的，由于每帧动画都必须要有一副图片对应，所以容量相对关键帧动画和蒙皮动画而言要大的多。因为关键帧动画和蒙皮动画只需要在关键的时间轴上设定数据，如位置、方向、旋转角度等，其他时间点的动画会实时生成，但带来的负面效果就是动画不够细腻。

好了，说了这么多，下面来看看怎么实现逐帧动画。事实上在网页中常见的 gif 动画就是逐帧动画最好的一个体现了，从原理上来说，准备好几张连续的图片，然后设定每张图片持续的时间，通过时钟进行播放就可以了。对于简单的动画，可能几张图片就可以了，但是对于流畅度要求较高，比较复杂的动画而言，有可能就需要很多张图片。为了便于管理和效率，特别是在网络上传输，通常情况下会把一个精灵的动画制作在一张图片中，比如，图4-10和图4-11分别显示了一个爆炸的特效序列图以及一个动画角色的行动序列图（两图分别取自于游戏《植物大战僵尸》和《超级玛丽》）。

图4-10 爆炸特效序列图

图4-11 玛丽行动序列图

图4-10是一个爆炸序列图，整张图片都是为了完成一个爆炸的效果，而玛丽的行动序列则包含了行走、跳跃、飞行和蹲下的效果。在设计当中，如果觉得动作行为很多但动作简单，可以把这些动作合到一张图片上，如果行动多而且复杂，则可以根据需要，把每个行为的动作图片合成一张，分类处理。

图4-12 动画序列图生成案例

接下来，先做个简单的尝试，完成一个简单的例子，该例子的设计如图4-12所示，在该例子中，可以点击选择文件按钮选取本地的一个图像文件，当然，为了实现动画效果，所以最好是带有动画序列的，比如类似上面的图片。选取后，需要设定每帧图片的宽度和高度，为了简单起见，最好大张的图片中，每帧小图片的高度和宽度一致，便于自动分解成每帧图片。另外一个需要设置的是行数和列数，行数和列数用来确定每帧图片在大图中的序号，比如在图4-10中的图片就是一个1行10列的动画序列图。设定好了数据后，单击生成动画序列图按钮，自动把整张图片分解成每帧的序列图，可以通过查看动画帧下拉列表查看每帧的图片，也可以通过运行动画按钮，运行整个动画，下面来看完整代码的实现。

<!DOCTYPE html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<body>

选取带有序列图的图片<input type="file" id="fBtn" onchange="loadImg(this)" /><br>

<img src="" id="fImg" />

每帧图片宽度:<input id="fWid" value="284"/><br>

每帧图片高度:<input id="fHei" value="324"/><br>

行数<input id="fRow" value="1" /><br>

列数<input id="fCol" value="10" /><br>

<input type="button" id="cBtn" onclick="createAnimation()" value="生成动画序列图"/>

查看动画帧<select id="selFrame" onchange="selFrame(this.value)"></select>

<input type="button" value="运行动画" onclick="run(this)" />

<br>

<canvas id="cv"></canvas>

</body>

<script src="js/jquery.js"></script>

<script>

//定义动画集合

var frames =[];

var timeHandler = null;

//加载本地图片

function loadImg(fObj)

{

var f=fObj.files[0];

if(!/image\/\w+/.test(f.type))

{

alert("请选择图片文件!");

}

else

{

//创建文件读取对象

var r = new FileReader();

//以DataURL方式读取

r.onload = function()

{

$("#fImg").attr("src", this.result);

delete r;

}

r.readAsDataURL(f);

}

//停止动画

if(timeHandler!=null)

{

clearInterval(timeHandler);

}

frames = [];

}

//生成动画

function createAnimation()

{

if($("#fImg").attr("src")=="")

{

alert("清先选择图片文件");

}

else

{

//获取每帧宽度和高度

var w = Number($("#fWid").val())||32,

h = Number($("#fHei").val())||32,

r = Number($("#fRow").val())||1,

c = Number($("#fCol").val())||1;

frames = [];

cv.width = w;

cv.height = h;

//清空帧选择

$("#selFrame").empty();

//生成每帧图片在图中的坐标

var fCount = 0;

for(var i = 0;i<r;i++)

{

for (var j=0;j<c;j++)

{

//计算每帧图片的起始坐标, x, y

var x = j*w,

y = i*h;

//保存每帧图片的起始坐标, x, y 和宽度高度

frames.push([x, y, w, h]);

$("#selFrame").append("<option value='"+fCount+"'>第"+fCount+"帧</option>");

fCount++;

}

}

}

}

//选择动画帧，把当前帧画到画布上面

function selFrame(idx)

{

if(frames.length==0)return;

var f = frames[idx],

ctx = cv.getContext("2d");

//绘制图片到canvas上

ctx.clearRect(0, 0, cv.width, cv.height);

ctx.drawImage(fImg, f[0], f[1], f[2], f[3], 0, 0, cv.width, cv.height);

}

//运行动画

function run(btn)

{

if(frames.length==0)return;

var ctx = cv.getContext("2d");

if(btn.value=="运行动画")

{

//设置动画,每100毫秒运行一帧

var fCount = frames.length,

fIdx = 0;

timeHandler = setInterval(function(){

var f = frames[fIdx];

ctx.clearRect(0, 0, cv.width, cv.height);

ctx.drawImage(fImg, f[0], f[1], f[2], f[3], 0, 0, cv.width, cv.height);

fIdx=(++fIdx)%fCount;

}, 100);

btn.value="停止动画";

}

else

{

clearInterval(timeHandler);

btn.value="运行动画";

}

}

</script>

</html>

本例只是简单实现了分解集合在一张图片上的动画序列图，然后通过定时动画完成了动画序列的显示功能，代码中主要的方法是createAnimation和run方法。createAnimation方法会把序列图片进行拆解，我们在绘制动画的时候只需要知道每帧图片在序列图中的坐标和宽度高度就够了，所以，在frames集合对象中保存了每帧图片在序列图中的坐标偏移位置和宽度高度。不过，对于大多数应用而言，为了方便，通常情况下每帧图片的宽高度都相同，但也不排除例外的情况。有些时候为了节约资源、提高资源利用率、减少网络流量，有可能会把大小完全不同的图片集中放在一起，常见的比如把RPG游戏中的地图、道具等集合到一张图片上，这时候就需要调整每帧图片的宽度和高度了。

通过createAnimation方法把动画序列图分解到frames中后，实际上frames只是保存了每帧图片在序列图中的位置坐标而已。至于如何实现动画，实际上就是通过定时器定时显示每一帧图片而已，所以在代码中的run方法设置了每100毫秒进行播放，然后循环按序列图的帧顺序进行播放，最终就形成了动画。

有了以上这个简单例子为基础，下面我们就来看看，如何实现对帧动画的一个封装类，并且把它集成在游戏引擎中。

事实上，通过以上的案例得知，帧动画的封装类实际上就是一系列的每帧动画的集合而已，于是，我们就得到动画集的代码，把它放在xengine目录中，叫xframes.js，代码如下：

(function(win){

//帧动画对象

var _frames = win.Frames = Class.extend({

init:function(name, img, duration)

{

//帧动画名称

this.name = name;

//帧动画每帧所持续的时间

this.duration = duration|50;//默认每帧持续50 毫秒

//保存每帧位置，和持续时间信息

this.frames = [];

//对应的动画帧序列图,

this.img = img;

},

//添加帧数据

add:function(x, y, w, h, img, dur)

{

var dur=dur||this.duration,

img = img||this.img;

this.frames.push([img, x, y, w, h, dur]);

},

//插入帧数据

insert:function(idx, x, y, w, h, img, dur)

{

var dur=dur||this.duration,

img = img||this.img;

ArrayUtil.insert(this.frames, idx, [img, x, y, w, h, dur]);

},

//移出帧数据

remove:function(idx)

{

this.frames.removeIdx(idx);

},

//删除所有帧

clear:function()

{

this.frames = [];

},

//获取帧数据

get:function(idx)

{

return this.frames[idx];

},

//获取总数

getCount:function()

{

return this.frames.length;

}

});

//帧动画集合对象，保存一组帧动画集合

var _animations = win.Animations = Class.extend({

init:function()

{

//保存所有动画帧

this.anims = {};

},

//添加帧动画集合

add:function(name, frames)

{

this.anims[name]=frames;

},

//删除帧动画集合

remove:function(name)

{

this.anims[name]=null;

},

//清空帧动画集合

clear:function()

{

this.anims={};

},

//获取当前帧动画

get:function(name)

{

return this.anims[name];

}

})

//帧动画控制对象

var _frameCtrl = win.FrameCtrl = Class.extend({

init:function(processFrameFN)

{

//默认动画处理函数

function defProcessFrame()

{

//计算上一帧到现在的时间

this.fDur += FrameState.elapseTime*this.speed;

//如果超过当前帧的持续时间就切换到下一帧

if(this.fDur>=this.currFrames.frames[this.currFIdx][5])

{

this.fDur=0;

if(this.currFIdx<this.feIdx-1)

{

++this.currFIdx;

}

else

{

if(this.isCycle)

{

this.currFIdx=this.fsIdx;

}

}

}

}

//设置动画处理函数

this.processFrame = processFrameFN||defProcessFrame;

},

//复位所有属性

reset:function()

{

//开始帧索引

this.fsIdx = 0;

//结束帧索引

this.feIdx = this.currFrames.getCount();

//当前运行帧索引

this.currFIdx = 0;

//是否循环

this.isCycle = true;

//当前帧已经持续的时间

this.fDur = 0;

//动画速度

this.speed = 1;

},

//设置当前帧动画

setCurrent:function(name)

{

var cFrames=this.anims.get(name);

if(this.currFrames!=cFrames)

{

var oSpeed=this.speed||1;

this.currFrames = cFrames;

this.reset();

this.speed = oSpeed;

}

},

//获取当前帧动画

getCurrent:function()

{

return this.currFrames;

},

//设置frames

setAnims:function(animations, currAnimName)

{

this.anims = animations;

currAnimName = currAnimName||"def";

//设置当前动画帧集

this.setCurrent(currAnimName);

},

getCurrFrameIdx:function()

{

return this.currFIdx;

},

//获取当前帧

getCurrFrame:function()

{

return this.currFrames.get(this.currFIdx);

},

//获取下一帧

getNextFrame:function()

{

this.processFrame();

return this.currFrames.get(this.currFIdx);

}

})

//类名称

_frames.ClassName = "Frames";

_frameCtrl.ClassName ="FrameCtrl";

_animations.ClassName = "Animations"

//注册类到类工厂中

ClassFactory.regClass(_frames.ClassName, _frames);

ClassFactory.regClass(_frameCtrl.ClassName, _frameCtrl);

ClassFactory.regClass(_animations.ClassName, _animations);

}(window))

在对于帧动画的代码中，涉及了3个主要类，即Frames类、Animations类和FrameCtrl类。Frames类我们可以把它看作是帧动画的一个集合类，它只是单纯的保存每帧图片的基本信息，包含了对帧的添加和删除操作。

Frames只是表示单个动画，而Animations不一样，它可以说是Frames的集合，也就是说Animations表示的是一组动画集合。通常情况下，我们会把精灵的一组动画集合在一起用Animations表示。Frames中使用了一个数组frames保存每帧图片的信息，添加动画帧的代码如下：

//添加帧数据

add:function(x, y, w, h, img, dur)

{

var dur=dur||this.duration,

img = img||this.img;

this.frames.push([img, x, y, w, h, dur]);

},

可以看到，每帧图片信息实际上保存了img、x、y、w、h、dur信息，img表示对应的img元素。通常情况下，一组动画帧会集中到一张图片上，但也有很多情况下会使用多个图片组成一组动画帧，所以使用该参数指向图片元素。x、y、w、h分别是该帧在动画序列图中的偏移和宽度、高度。最后一个参数 dur 表示当前动画帧所持续的时间长度，通过这个参数可以调整帧动画播放的速度。比如，假设每帧图片持续50毫秒，对于10帧的图片，播放时长就是500毫秒，如果每帧图片设置成100毫秒，则总时长就是1秒钟了，一般来说这个参数对于每帧图片而言都是一样的，但也不排除例外，所以保留这个属性便于更改。

Frames表示的是单个动画的序列帧集，而Animations表示的是一组动画序列帧集，本质上类似于HashMap保存信息。

有了Animations之后，Animations中保存了各个动画的序列图的基本信息，但是，如何把这些序列图进行播放呢？这个时候我们把对于控制序列图播放的功能独立出一个类叫FrameCtrl，这个类主要是用于设置当前Animations使用哪组动画并且控制动画播放的，根据具体的功能需要对帧动画集进行处理，把帧动画集合和控制帧集合分开是为了更好地进行扩展。对于动画的播放方式，用户可以进行定制。在 FrameCtrl 中最重要的方法是 processFrame 方法和 getNextFrame 方法， getNextFrame 方法用于获取下一个动画帧，在该方法中先调用了processFrame 方法。processFrame 方法的主要作用是根据当前运行的时间进行处理动画帧，设置当前帧的索引号，processFrame有一个默认的处理方式，代码如下：

//默认动画处理函数

function defProcessFrame()

{

//计算上一帧到现在的时间

this.fDur += FrameState.elapseTime*this.speed;

//如果超过当前帧的持续时间就切换到下一帧

if(this.fDur>=this.currFrames.frames[this.currFIdx][5])

{

this.fDur = 0;

if(this.currFIdx<this.feIdx-1)

{

++this.currFIdx;

}

else

{

if(this.isCycle)

{

this.currFIdx=this.fsIdx;

}

}

}

}

在默认的处理函数中，processFrame 会累计当前帧实际持续的时间，如果当前帧持续的时间超过了该帧应当持续的时间，则会把当前帧的索引指向下一个可用帧。函数中使用FrameState.elapseTime这个属性，从之前的控制游戏帧数章节中得知，可以通过FrameState这个类得到每一个游戏帧的信息。而elapseTime表示当前游戏帧执行的时间，由于在游戏中的每一帧中会对游戏精灵进行渲染，所以，我们可以根据这个时间来得到当前动画应该显示的那一帧。为了达到控制动画显示速度的问题，我们使用在累计当前动画帧实际持续的时间的时候使用FrameState.elapseTime和speed属性的乘积，speed越小，速度越快，反之则越慢。

好了，关于帧动画控制这一部分已经放到 xengine 中，下面来看一个具体使用帧动画控制的例子。图4-13是该例子的一个截图，在例子中，首先有一张动作序列图，点击开始按钮，就会根据序列图生成动画，在动画下拉列表中可以选择人物当前的动作，在速度下拉列表中可以选择当前动画运行的速度（该图取自于游戏《超级玛丽》）。

图4-13 帧控制动画实例

代码如下：

<!DOCTYPE html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<body>

<img src="images/mr.png" id="mr" /><br>

<input type="button" id="btnStart" value="开始" onclick="start()"/>

动画<select id="sp" onchange="changeAnimation(this.value);">

<option value="run" selected>跑步</option>

<option value="jump">跳跃</option>

<option value="crouch" >蹲下</option>

</select>

速度<select id="sp" onchange="changeSpeed(this.value);">

<option value="0.25">X0.25</option>

<option value="0.5">X0.5</option>

<option value="1" selected>X1</option>

<option value="2">X2</option>

<option value="4">X4</option>

</select><br>

<canvas id="cv" width="64" height="64"></canvas>

</body>

<script charset='utf-8' src="xengine/xcore.js"></script>

<script charset='utf-8' src="xengine/xutil.js"></script>

<script charset='utf-8' src="xengine/xsys.js"></script>

<script charset='utf-8' src="xengine/xframes.js"></script>

<script>

//定义动画

var anims = new Animations();

//帧控制器

var fCtrl = null;

//时钟句柄

var tHand = null;

//初始化，创建帧集合和帧控制对象

function initAnim()

{

//定义跑的动画

var runFrames = new Frames("m_run", document.getElementById("mr"));

//定义蹲下动画

var crouchFrames = new Frames("m_crouch", document.getElementById("mr"));

//定义跳跃动画

var jumpFrames = new Frames("m_jump", document.getElementById("mr"));

//添加跑步帧

for(var i=0;i<3;i++)

{

runFrames.add(32*i, 0, 32, 32);

}

//添加跳跃帧

jumpFrames.add(0, 0, 32, 32);

jumpFrames.add(32*6, 0, 32, 32);

//添加蹲下帧

crouchFrames.add(0, 0, 32, 32);

crouchFrames.add(32*14, 0, 32, 32);

//创建动画集合

anims.add("run", runFrames, true);

anims.add("jump", jumpFrames);

anims.add("crouch", crouchFrames);

//定义动画控制对象

fCtrl = new FrameCtrl();

fCtrl.setAnims(anims, "run");

}

//开始动画

function start()

{

var ctx=document.getElementById("cv").getContext("2d");

var btnS = document.getElementById("btnStart");

if(btnS.value=="开始")

{

//开启帧数跟踪

FrameState.start();

tHand = setInterval(function(){

//更新帧状态

FrameState.update();

//获取动画当前帧

var f = fCtrl.getNextFrame();

ctx.clearRect(0, 0, 64, 64);

//绘制帧

ctx.drawImage(f[0], f[1], f[2], f[3], f[4], 0, 0, 64, 64);

}, 20);

btnS.value="停止";

}

else

{

btnS.value="开始";

clearInterval(tHand);

}

}

//修改动画速度

function changeSpeed(val)

{

fCtrl.speed=val;

}

//修改当前动画

function changeAnimation(val)

{

fCtrl.setCurrent(val);

}

initAnim();

</script>

</html>

在该例子中，我们只需要使用引擎中的Frames、Animations和FrameCtrl的功能，所以不需要使用游戏类和场景类。

首先创建Animations对象，然后使用initAnim方法创建帧集以及帧控制对象。在例子中使用了人物3种不同的动作，所以创建了三种帧动画集合，分别是跑步、蹲下和跳跃，然后把这三种动画帧序列图添加到Animations对象中，通过动画下拉列表选项可以选择相应的动画，速度下拉列表可以选择动画的速度。单击开始按钮的时候，启用了时钟控制器，调用了 start()方法，在start()方法中主要涉及以下几行代码：

//更新帧状态

FrameState.update();

//获取动画当前帧

var f = fCtrl.getNextFrame();

//清除canvas

ctx.clearRect(0, 0, 64, 64);

//绘制帧

ctx.drawImage(f[0], f[1], f[2], f[3], f[4], 0, 0, 64, 64);

为了能跟踪游戏帧状态，所以调用了 FrameState 的 update()方法，然后调用帧动画控制器的getNextFrame()方法，得到当前应该显示的帧之后，绘制当前的帧图片到canvas上。

虽然，到此为止关于帧动画已经有了一个简单的实现，但是我们还没有把它和游戏精灵结合起来。没有和游戏精灵结合起来，它只是一个没有灵魂的外壳，下面来看看如何把它应用到游戏精灵中去。我们在xengine中增加xsprite.js，让它作为xengine的一部分。

以下是游戏精灵xsprite.js的代码：

(function(win){

//渲染对象类

var _sprite= win.Sprite = RenderObj.extend({

init:function(name)

{

this._super(name);

//帧动画集合对象

this.anims = null;

this.animsCtrl = new FrameCtrl();

//是否水平反向

this.isXFlip = false;

//是否垂直反向

this.isYFlip = false;

this.scaleX = 1;

this.scaleY = 1;

},

//设置帧动画集合对象

setAnims:function(animations, currAnimName)

{

currAnimName=currAnimName||"def";

this.anims = animations;

this.animsCtrl.setAnims(animations, currAnimName);

},

//设置动画集

addAnim:function(name, frames, isCurrent)

{

this.anims.add(name, frames);

isCurrent&&this.animsCtrl.setCurrent(name);

},

//删除指定名称动作

removeAnim:function(name)

{

this.anims.remove(name);

},

//按名称设置当前动作

setCAnim:function(name)

{

this.animsCtrl.setCurrent(name);

},

//设置动画速度

setAnimSpeed:function(sp)

{

this.animsCtrl.speed=sp;

},

//按名称获取动作

getAnim:function(name)

{

return this.anims.get(name);

},

//获取当前运行的动画

getCurrentAnim:function()

{

return this.animsCtrl.getCurrent();

},

//获取当前运行帧

getCurrFrame:function()

{

return this.animsCtrl.getCurrFrame();

},

//获取下一帧信息

getNextFrame:function()

{

return this.animsCtrl.getNextFrame();

},

//渲染方法，每一帧调用，ctx是canvas环境

render:function(ctx)

{

ctx.translate(this.x, this.y);

var hw = 0.5*this.w;

var hh = 0.5*this.h;

var scaleX = (this.isXFlip)?-this.scaleX:this.scaleX;

var scaleY = (this.isYFlip)?-this.scaleY:this.scaleY;

if(this.deg!==0)

{

ctx.rotate(MathUtil.deg2rad(this.deg));

}

ctx.scale(scaleX, scaleY);

var f = this.getNextFrame();

ctx.drawImage(f[0], f[1], f[2], f[3], f[4], -hw, -hh, this.w, this.h);

}

});

_sprite.ClassName = "Sprite";

//注册类到类工厂中

ClassFactory.regClass(_sprite.ClassName, _sprite);

}(window))

以上是关于Sprite类的具体实现，直接从RenderObj类继承而来，和之前的RenderObj相比而言最大的区别是，RenderObj 并不提供 Render 方法的完整实现。前面也说了，实际上RenderObj是一个抽象对象，而Sprite类则提供了Render方法的默认实现。

Sprite类中作为精灵类的实现，增加了动画序列图属性，而在具体的Render方法中，主要是通过Sprite对象的当前帧进行绘制，Sprite中的核心渲染方法如下：

//渲染方法，每一帧调用，ctx是canvas环境

render:function(ctx)

{

ctx.translate(this.x, this.y);

var sw = this.scaleX*this.w;

var sh = this.scaleY*this.h;

var hw = 0.5*this.w;

var hh = 0.5*this.h;

var scaleX = (this.isXFlip)?-this.scaleX:this.scaleX;

var scaleY = (this.isYFlip)?-this.scaleY:this.scaleY;

if(this.deg!==0)

{

ctx.rotate(MathUtil.deg2rad(this.deg));

}

ctx.scale(scaleX, scaleY);

var f = this.getNextFrame();

ctx.drawImage(f[0], f[1], f[2], f[3], f[4], -hw, -hh, this.w, this.h);

}

Sprite 渲染方法的主要思想是根据当前精灵的状态信息，如位置、旋转角度、水平和垂直位置缩放比例，以及当前应该渲染的动画帧等进行绘制。

在Sprite的渲染方法中，首先使用translate方法平移坐标原点到Sprite的位置，然后判断当前对象的角度是否为零，如果不为零的话，则进行旋转操作。旋转操作在canvas中是非常耗时间的操作，能不用的话尽量不要使用旋转操作。旋转后，使用scale方法对精灵进行缩放操作，这里使用了isXFlip来判断是否进行水平翻转，如果isXFlip为true则表示进行水平翻转，同理， isYFlip则表示垂直翻转。有了这两个参数之后，在制作精灵动画帧的时候，对于镜像的图片可以只用制作一个，比如人物横向走动，只用制作面向左或者右的就可以了。设置好了变换矩阵之后，然后通过getNextFrame()方法获取Sprite当前应该显示的帧图片，最后就可以通过canvas的drawImage方法绘制Sprite了。

好了，关于Sprite的精灵类已经实现，接下来看看关于这个精灵类是如何应用到游戏当中去的。

下面这个例子中，将会产生 30 个速度不同的玛丽来回在窗口中走动，运行截图如图 4-14所示。

图4-14 随机行走的玛丽

前面讲解了引擎中Sprite类的实现，以后游戏中具体的精灵类都可以在Sprite基础上进行扩展。比如，在本例子中，首先，建立一个mario.js文件，保存Mario精灵类，Mario类的代码如下：

(function(){

//从引擎的Sprite继承

Mario = Sprite.extend({

update:function()

{

var w=this.owner.w,

h = this.owner.h;

//到达边界改变x速度方向

if(this.x<20||this.x>w-20)

{

this.dx=-this.dx;

this.isXFlip = (this.dx<0);

};

this._super();

}

})

//注册玛丽到类工厂

Mario.ClassName = "Mario";

ClassFactory.regClass(Mario.ClassName, Mario);

}())

Mario类直接继承自Sprite对象，在本例中复写了Sprite类的update方法。前面介绍引擎实现中说过了，update 方法主要用来根据游戏的具体逻辑实现精灵的动作更新，在本例中则是来回走动的逻辑。

有了Mario类的实现之后，来看看具体怎么运用Mario类，以下sprite.html文件则是实现例子效果的代码：

<!DOCTYPE html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<body>

<!--玛丽动作序列帧-->

<img src="images/mr.png" id="mr" style="display:none" /><br>

</body>

<script src="js/jquery.js"></script>

<!--导入引擎js部分-->

<script charset='utf-8' src="xengine/xcore.js"></script>

<script charset='utf-8' src="xengine/xutil.js"></script>

<script charset='utf-8' src="xengine/xevent.js"></script>

<script charset='utf-8' src="xengine/xsys.js"></script>

<script charset='utf-8' src="xengine/xrenderobj.js"></script>

<script charset='utf-8' src="xengine/xscene.js"></script>

<script charset='utf-8' src="xengine/xscenemanager.js"></script>

<script charset='utf-8' src="xengine/xframes.js"></script>

<script charset='utf-8' src="xengine/xsprite.js"></script>

<script charset='utf-8' src="xengine/xapp.js"></script>

<!--导入游戏js部分-->

<script charset='utf-8' src="js/mario.js"></script>

<script>

var g = new Game();

//初始化游戏

function initGame()

{

//获取场景管理器

var scm = g.sceneManager;

//创建场景

var sc = scm.createScene([{"w":640, "h":400}]);

initRenderObj(sc);

}

//初始化创建精灵

function initRenderObj(sc)

{

//设置跑步动画

var runFrames = new Frames("m_run", document.getElementById("mr"));

var jumpFrames = new Frames("m_jump", document.getElementById("mr"));

var crouchFrames = new Frames("m_crouch", document.getElementById("mr"));

//取前三幅跑步图片

for(var i=0;i<3;i++)

{

runFrames.add(32*i, 0, 32, 32);

}

//添加跳跃帧

jumpFrames.add(0, 0, 32, 32);

jumpFrames.add(32*6, 0, 32, 32);

//添加蹲下帧

crouchFrames.add(0, 0, 32, 32);

crouchFrames.add(32*14, 0, 32, 32);

//创建动画集合

var anims = new Animations();

anims.add("run", runFrames, true);

anims.add("jump", jumpFrames);

anims.add("crouch", crouchFrames);

//创建30个mario

for(var i=0;i<30;i++)

{

var mr = sc.createRObj(Mario.ClassName, ["mr"]);

//设置随机位置

mr.moveTo(MathUtil.randInt(20, 620), MathUtil.randInt(20, 380));

//设置随机速度0~3

mr.dx = MathUtil.randInt(1, 3);

mr.setAnimSpeed(2*Math.random());

mr.w = mr.h = 64;

mr.setAnims(anims, "run");

}

}

//初始化游戏

initGame();

//开始飞奔

g.run(-1);

</script>

</html>

引擎的使用方式大体上和屏幕弹球基本一致，大体上是先初始化游戏，然后在调用 Game的run方法运行，在初始化游戏的过程中会调用initRenderObj创建游戏精灵。在initRenderObj方法中首先产生3个运动帧，因为在游戏中一个角色可能会有多个动作，本例使用了如跑步、蹲下及跳跃3个动作：

var runFrames = new Frames("m_run", document.getElementById("mr"));

var jumpFrames = new Frames("m_jump", document.getElementById("mr"));

var crouchFrames = new Frames("m_crouch", document.getElementById("mr"));

玛丽的3个运动帧动画存放在同一张图片中，所以在初始化动画帧的时候需要对图片中的动作进行拆解：

for(var i=0;i<3;i++)

{

runFrames.add(32*i, 0, 32, 32);

}

//添加跳跃帧

jumpFrames.add(0, 0, 32, 32);

jumpFrames.add(32*6, 0, 32, 32);

//添加蹲下帧

crouchFrames.add(0, 0, 32, 32);

crouchFrames.add(32*14, 0, 32, 32);

这样处理完成之后就知道每一帧图片在原序列帧图片中的位置坐标。

创建完动画帧集合之后，需要把这些动画帧集合增加到Animations中：

var anims = new Animations();

anims.add("run", runFrames, true);

anims.add("jump", jumpFrames);

anims.add("crouch", crouchFrames);

接下来，通过for循环产生30个玛丽，在使用Scene创建玛丽的时候，注意这个时候调用createRObj方法传递的是Mario的ClassName以及相应的参数：

var mr = sc.createRObj(Mario.ClassName, ["mr"]);

产生玛丽的实例之后，需要给玛丽设置相应的其他数据，比如初始位置、随机速度等。

这一切准备好之后，运行游戏，玛丽就开始了它不知疲倦的来回移动，直到你关闭浏览器。

这一小节中实现了游戏中非常重要的一个类 Sprite，只要有足够的动画资源，就可以让你的游戏显得五彩缤纷了。有一个好的美术人员将会大大提高你游戏的生命力，谁叫人都是爱美的呢？

在这个例子中，对于Sprite精灵类的用法有了一个基本的展示，但有些问题，比如每次创建精灵类的时候，需要先创建帧动画，然后给精灵设置动画帧，然后还要把这些动画帧添加到Animations 对象中，遇到复杂的动画帧还需要拆解。如果碰到大量的精灵类，通过这种方式创建精灵则是比较重复和枯燥无味的工作，接下来的章节中我们来看看如何通过配置文件的方式加载游戏中的各种资源，达到自动给游戏精灵设置动画的目的。

4.2.9 加载游戏资源

通常在玩游戏的过程中最让人心烦的是什么？刷怪被人砍、不断弹出的广告、游戏画面卡等，很多方面。但当你第一次玩这个游戏的时候，最让人焦急的是游戏开头的Loading…，特别是RPG类型或者SLG类型。这段让人等的心焦的时刻，游戏干嘛去了啊？当然是加载各种资源去了。

游戏中展现的所有元素都可以看成资源，比如图片、音乐、音效、信息等。如何把这些游戏中应用到的这些资源整合和管理起来，对于游戏来说非常重要。如果游戏一开始由于加载资源而让用户太长时间的等待，未必是件好事情，说不定用户因此而放弃该游戏。

在有些游戏当中，会根据游戏资源的不同分成不同的文件夹单独存储，如effect、sound、bgm、map、sprite等。在这些文件夹当中，会存放不同类型的文件，比如sound文件夹中存放各种.mp3文件，sprite中存放各种精灵的动作序列图文件.bmp或者.png，map中存放游戏的地图文件。这类方式存储的资源一般都是没有经过处理的，很容易被用户直接获取。有的游戏基本上就只有几个很大的文件比如xxx.dat1、xxx.dat2，这类型的文件基本是把游戏中所应用到的各种资源打包或者序列化成一个二进制文件，防止用户直接获取游戏中的资源。再做得稍微复杂点的，为了容量和安全考虑，还会把这些资源压缩加密。当然，使用 JavaScript 开发中，并不能直接处理二进制数据，即使能通过 FileReader 读取二进制文件，但读出来之后还得自己想办法进行反序列化操作，这是一件比较痛苦的事情。所以在本节例子中不会通过二进制的方式打包和压缩管理所有的资源文件，将会使用JavaScript比较擅长的方式，比如使用JSON的方式组织和管理游戏中出现的所有资源。

游戏的资源类型有很多种，就目前我们所应用到的资源主要有两类，一类是图片资源，另一类是游戏精灵资源。接下来我们先把这两类资源通过JSON组织起来，下面的JSON代码是组织资源的一种形式：

{

"image":

[

{"name":"mario", "src":"img/mr.png"},

{"name":"title", "src":"img/title.png"}

],

"frame":

[

{"name":"sprite", "src":"data/sprite_frames.json"}

]

}

在以上的JSON配置文件，配置了两种类型的资源，一种是image图像资源，另一种是frame帧动画集资源。在图片资源中，我们把所有需要使用的图片放到数组中，数组中的每一项是一个对象，该对象由名称和源文件位置组成，如{"name":"mario", "src":"img/mr.png"}表示该图片资源的名称叫mario，源文件的位置是img/mr.png，如果有多个图片，则在数组中逐个添加。

frame 帧动画集资源比较特殊，它也是通过数组存放每一个帧动画集合，游戏中用到的帧动画对象都放到该数组中。由于游戏中的帧动画比较多，比如，有的是用于背景，有的用于地图，有的用于NPC，有的用于菜单。为了便于分类管理，每一类动画集合的数据分散到另一个JSON 对象文件中详细描述，如{"name":"sprite", "src":"data/sprite_frames.json"}定义一个名称叫sprite的帧动画对象，具体的帧动画的数据在src指定的data/sprite_frames.json文件中，如果还有用于描述NPC 的，我们可以定义诸如：{"name":"npc", "src":"data/npc_frames.json"}，当然，还可以根据需要分得更细。对于大型的应用来说，把资源文件分得更细将更加容易维护和管理，接下来看看sprite_frames.json文件是如何描述精灵的帧动画的。

sprite_frames.json代码：

{

"title":{

"type":0,

"img":"title"

},

"mario":{

"type":1,

"img":"mario",

"rc":[1, 15, 32, 32],

"animations":{

"run":[0, 2],

"jump":[6]

}

}

}

帧动画数据按照每个对象进行存储，通过该对象对帧图片的描述，可以通过程序产生动画序列帧对象，每个对象的描述格式采用如下形式。

"帧动画名称"：指定当前帧动画名称

{

"类型"：指定帧图像分解类型，0：表示不带动画帧，1：表示带有动画帧

"图片资源名称"：指定的图片资源文件名称，该名称必须在资源文件中有定义

"帧描述信息"：通过数组描述帧信息，格式为[帧动画行数，帧动画列数，每帧图片宽度，每帧图片高度]，

比如:[1，15，32，32]表示图片有1行15列帧动作帧，每张图片宽度和高度都是32像素

"动画集合"：动画集合用于存放每组动画帧在集合中的索引

{

"动作1"：用数组表示集合中的动画帧索引，比如:[0，2]表示获取动画帧集合中的0, 1, 2张图片

"动作2"：

……

}

}

事实上，我们应该可以通过定义的帧动画数据，通过程序来产生帧动画数据，可以通过很多种方式定义，以上是其中的一种。

根据帧动画格式的不同，暂时我们把帧动画分为两类，在格式中通过"类型"定义，1表示该精灵不含任何动画。对于这种情况，只需要指定该精灵对应的图片资源即可，不需要指定动画信息，对应的图片资源名称从资源配置文件中指定。类型1表示该精灵的帧动画采用固定的格式，图4-15所示的图包含15张小的动画帧，这15张图片大小都是32×32大小，均匀分布成1行15列，所以帧描述信息为[1, 15, 32, 32]。而对于图4-16的魔法序列图片包含了6张动画帧，均匀分布成2行3列，帧描述信息为[2, 3, 64, 64]。接下来的信息是用来描述动画集合的，对于一个精灵来说，可能有多种动画，每个动画都包含若干动作帧，对于这种每帧图片大小都一致的图片来说，描述动作帧可以直接使用在图片中的索引即可，比如"run":[0, 2]表示跑的动作使用第0～2帧的图片，"jump":[6]表示跳跃动作使用第7张图片。事实上对于这种图片格式比较规则的还比较好定义，但不排除另外一种情况，如图4-17所示的精灵图片不是规则的图片，无法采用以上的标准形式，这种情况需要采用其他方式定义，在这种情况下则需要定义每帧图片在大图片上的偏移位置，对于这类型的定义方式，聪明的读者可以尝试定义一下（该图取自于游戏《超级玛丽》）。

图4-15 规则的玛丽动作序列图

图4-16 规则的魔法序列图

图4-17 不规则的背景图

定义好了各种资源格式之后，我们就需要把这些资源通过程序读取出来，为此，在xengine中创建xresource.js文件用于资源的读取和加载。先来看看图片资源如何加载，这个相对来说比较容易，加载图片资源代码如下：

//图片资源

var _imgRes = win.ImageRes = {

//加载资源

load:function(name, url, loadedFN)

{

var img=new Image();

img.src=url;

var obj = {"type":"image", "name":name, "hEle":img, "src":url, "isLoaded":false};

img.onload=function()

{

obj.isLoaded=true;

loadedFN&&loadedFN();

}

return obj;

}

};

ImageRes对象目前只有一个方法load，需要3个参数，分别是名称、URL地址和加载完毕后的回调函数。因为这个加载方法是异步的，所以如果需要，必须给它指定一个回调函数以确保资源真正加载完毕后调用。调用这个方法返回一个 JSON 对象，图片资源对象有几个属性，分别是类型、名称、创建的HTML元素，以及源路径和是否加载完毕。

这样，对于在前面介绍的资源文件中假设定义了如下的图片资源对象：

var obj = {"name":"mario", "src":"img/mr.png"},

就可以通过ImageRes.load (obj.name, obj.src, function(){})的方法加载图片资源。

接下来，看看如何加载游戏精灵帧动画资源。对于这种方式的资源，稍微有点复杂，就像前面设计动画资源格式一样，需要分类型进行加载，具体代码如下：

//帧动画资源

var _frameRes = win.FrameRes = {

load:function(name, url, loadedFN)

{

//解析frameJSON格式配置文件

function parse(animations, data)

{

switch(data.type)

{

case 0:

var res = _resMan.getResByName(_imgRes.ClassName, data.img);

var fs = new Frames("def", res.hEle);

fs.add(0, 0, res.hEle.width, res.hEle.height);

animations.add("def", fs);

break;

case 1:

var res = _resMan.getResByName(_imgRes.ClassName, data.img);

if(res===null)

{

console.log("Load image for frames:"+data.img+"error !")

return;

}

var r = data.rc[0];

var c = data.rc[1];

var w = data.rc[2];

var h = data.rc[3];

var fs = data.animations;

//如果忽略则取全部

if(fs==null)

{

var fs=new Frames("def", res.hEle);

for(var i = 0;i<r;i++)

{

for(var j=0;j<c;j++)

{

fs.add(j*w, i*h, w, h);

}

}

animations.add("def", fs);

}

else

{

for(var fname in fs)

{

if(fs.hasOwnProperty(fname))

{

var fss = fs[fname];

var fm = new Frames(fname, res.hEle);

for(var j = fss[0];j<=fss[fss.length-1];j++)

{

var fx=j%c;

fx|=fx;

var fy = j/c;

fy|=fy;

fm.add(w*fx, h*fy, w, h);

}

animations.add(fname, fm);

}

}

}

break;

}

}

var obj = {"type":"frame", "name":name, "src":url, "frames":{}, "isLoaded":false};

//加载帧动画资源

ResUtil.loadFile(url, null, function(data){

obj.isLoaded = true;

for(var i in data)

{

if(data.hasOwnProperty(i))

{

var f = data[i];

obj.frames[i] = new Animations();

//解析帧动画资源

parse(obj.frames[i], f);

}

}

loadedFN&&loadedFN();

});

return obj;

}

}

帧动画资源加载对象FrameRes和ImageRes一样，提供了load方法，参数也是一样，需要名称、资源路径以及回调函数。在load方法中定义了一个parse方法，parse方法主要用于解析一个独立配置的JSON对象。在parse方法中会根据配置文件中帧动画集合的type属性分别进行处理，前面关于配置的设计类型中处理了两种不同的类型，一种是不含有动画的，另一种是规则形式的带动画的。对于不规则类型的处理方式用户可以自行设计处理，比如对于以下配置对象：

"mario":{

"type":1,

"img":"mario",

"rc":[1, 15, 32, 32],

"animations":{

"run":[0, 2],

"jump":[6]

}

}

通过parse对象解析之后将会得到一个完整的Animations对象，对于含有多个序列帧的配置对象则采用循环的方式通过parse方式进行解析。

整个配置文件采用了JSON格式文件，在加载配置文件的时候使用ResUtil.loadFile的方式异步加载配置文件，该方法直接使用JQuery的.ajax的方法加载文件，代码如下：

loadFile:function(fileURL, type, fn, sync)

{

var ct="text/xml;charset=UTF-8";

var dt = type||"json";

if(dt=="json")

{

ct="text/x-json;charset=UTF-8";

}

$.ajax(

{

url:fileURL,

async:(sync==null?true:sync),

type:"POST",

dataType:dt,

contentType:ct,

error:function()

{

console.log("Load File ["+fileURL+"] Error!");

},

success:function(data)

{

console.log("Load File ["+fileURL+"] Successful!")

fn(data);

}

}

);

}

两种资源类型的加载已经基本完成，提供了一种加载资源的案例，以后如果有其他类型的资源可以采取类似的方式进行处理。

关于ImageRes和FrameRes这两个类可以独立使用，为了便于用户方便地管理各种资源，我们设计了一个ResManager资源管理类可以对于各种资源进行管理，这个类包含了游戏中所有资源的实例，并且能够通过ResManger统一创建和维护这些实例。

ResManager就像一个门面（Facade）一样，通过它可以对外统一地处理各种资源，比如创建、删除和读取资源文件等功能。

先来看看创建各种资源的方式，比如我们希望可以通过ResManager.load()的方式加载各种资源，不论是ImageRes或者FrameRes，对外来说应该都使用统一的方式调用。于是，ResManager. load方法可以设计成function load (type, name, src, loadedFN)，type 表示加载的资源类型，其他3个参数和ImageRes或者FrameRes一样，为了实现这个函数，可以采用如下伪代码方式：

function load(type, name, src, loadedFN)

{

if(type=="image") return ImageRes.load(name, src, loadedFN)

else if(type=="frame") return FrameRes.load(name, src, loadedFN)

else if(type=="other")...

/*其他资源等*/

}

采用这种方式进行处理，实现代码中会充斥大量的if…else…代码，不太便于扩展，为了方便维护，采用了以下方式：

//加载资源

load:function(type, name, src, loadedFN)

{

var res = this.getClass(type).load(name, src, loadedFN);

this.addRes(res);

return res;

}

在这个实现的方法中，提供了 getClass (type)的方法，这个方法将会根据 type 返回该类型的加载器类。比如getClass ("image")将会返回ImageRes，为了实现getClass (type)方法，将会在ResManager中增加defTypes属性，该属性将会保存所有的加载器类，通过以下方法可以把不同的加载器类加入到defTypes中：

//注册资源类型

regResType:function(type, clz)

{

if(this.defTypes[type]==null)

{

this.defTypes[type]={"type":type, "class":clz};

}

}

然后，就可以通过 ResManager.regResType (ImageRes.ClassName, ImageRes)的方式注册到ResManager中，然后getClass获取加载器类，getClass方法实现如下：

//根据类型获取资源类

getClass:function(type)

{

return this.defTypes[type]["class"];

}

这样处理之后，以后有新的资源类型加载，只需要实现相应的功能，然后通过ResManager.regResType方法注册到ResManager中，就可以通过ResManager.load方法统一加载资源。除了管理各种资源加载器外，ResManager还需要管理通过各种通过加载器加载的资源，在ResManager中增加一个res集合对象，用于存储各种资源实例。

以下代码展示了ResManager的完整实现：

//游戏资源管理类

var _resMan = win.ResManager =

{

//存储所有定义的资源类型

defTypes:{},

//存储所有资源

res:{},

//注册资源类型

regResType:function(type, clz)

{

if(this.defTypes[type]==null)

{

this.defTypes[type]={"type":type,"class":clz};

}

},

//根据类型获取资源类

getClass:function(type)

{

return this.defTypes[type]["class"];

},

//加载资源

load:function(type, name, src, loadedFN)

{

var res = this.getClass(type).load(name, src, loadedFN);

this.addRes(res);

return res;

},

//添加资源

addRes:function(resObj)

{

this.res[resObj.type]=this.res[resObj.type]||{};

this.res[resObj.type][resObj.name]=resObj;

},

//删除指定资源

removeRes:function(resObj)

{

var t=resObj.type,

n = resObj.name;

delete this.res[t][n];

if(JSONUtil.isEmpty(this.res[t]))

{

delete this.res[t];

}

},

//清除所有资源

clearRes:function()

{

this.res={};

},

//根据名称获取资源

getResByName:function(type, name)

{

return this.res[type][name];

},

//获取帧动画资源

getAnimationsByName:function(fResName, fName)

{

var obj=this.res[_frameRes.ClassName][fResName];

var fm = obj.frames[fName];

return fm;

},

//加载资源，url指定资源配置文件

loadRes:function(url, loadedFN, perLoadedFN)

{

var self=this;

ResUtil.loadFile(url, null,

function(data){

self.parseRes(data, loadedFN, perLoadedFN);

})

},

//解析资源

parseRes:function(res, loadedFN, perLoadedFN)

{

var resCount=0;

var totalCount = 0;

var resType=[];

this.res=[];

for(i in res){

if(res.hasOwnProperty(i))

{

resType.push(i);totalCount+=res[i].length;

}

}

var cResTIdx=0;

var cRes=resType[cResTIdx];

var cResCount=0;

var loadObj = null;

var self = this;

var loadHand=window.setInterval(function(){

if(loadObj==null)

{

loadObj=self.load(cRes, res[cRes][cResCount].name,

res[cRes][cResCount].src);

}

else

{

if(loadObj.isLoaded)

{

resCount++;

cResCount++;

perLoadedFN&&perLoadedFN (totalCount, resCount);

if(resCount==totalCount)

{

window.clearInterval(loadHand);

loadedFN&&loadedFN(loadedFN);

return ;

}

if(cResCount>=res[cRes].length)

{

cResTIdx++;

cRes=resType[cResTIdx];

cResCount=0;

}

loadObj = self.load(cRes, res[cRes][cResCount].name,

res[cRes] [cResCount]. src);

}

}

});

}

}

最终，通过ResManager提供的loadRes方法可以直接加载之前我们定义好的res资源文件。loadRes方法有3个参数，url表示需要加载的资源文件，loadedFN表示加载完所有资源后的回调函数，perLoadedFN 表示每次加载完一个资源后的回调函数，这个回调函数主要是用来处理当前加载完成的进度，比如显示加载进度条。

ResManager完成之后，还是使用上节的例子，产生30个随机走动的玛丽。来看看如何使用ResManager加载配置文件，创建HTML5源文件，代码如下：

<!DOCTYPE html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

</head>

<body>

<!--玛丽动作序列帧-->

<img src="images/mr.png" id="mr" style="display:none" /><br>

</body>

<script src="js/jquery.js"></script>

<!--导入引擎js部分-->

<script charset='utf-8' src="xengine/xcore.js"></script>

<script charset='utf-8' src="xengine/xutil.js"></script>

<script charset='utf-8' src="xengine/xevent.js"></script>

<script charset='utf-8' src="xengine/xsys.js"></script>

<script charset='utf-8' src="xengine/xrenderobj.js"></script>

<script charset='utf-8' src="xengine/xscene.js"></script>

<script charset='utf-8' src="xengine/xscenemanager.js"></script>

<script charset='utf-8' src="xengine/xframes.js"></script>

<script charset='utf-8' src="xengine/xsprite.js"></script>

<script charset='utf-8' src="xengine/xresource.js"></script>

<script charset='utf-8' src="xengine/xapp.js"></script>

<!--导入游戏js部分-->

<script charset='utf-8' src="js/mario.js"></script>

<script>

var g = new Game();

//初始化游戏

function initGame()

{

//获取场景管理器

var scm = g.sceneManager;

//创建场景

var sc = scm.createScene([{"w":640, "h":400}]);

//加载资源

loadRes(sc);

}

//加载资源

function loadRes(scene)

{

ResManager.loadRes("data/res.json", function(){

//创建精灵

initRenderObj(scene);

//开始飞奔

g.run(-1);

});

}

//初始化创建精灵

function initRenderObj(sc)

{

//创建30个mario

for(var i=0;i<30;i++)

{

var mr = sc.createRObj(Mario.ClassName, ["mr"]);

//设置随机位置

mr.moveTo(MathUtil.randInt(20, 620), MathUtil.randInt(20, 380));

//设置随机速度0~3

mr.dx = MathUtil.randInt(1, 3);

mr.setAnimSpeed(2*Math.random());

mr.w = mr.h = 64;

//获取玛丽动画序列资源

var anims = ResManager.getAnimationsByName("sprite", "mario");

mr.setAnims(anims, "run");

}

}

//初始化游戏

initGame();

</script>

</html>

loadRes方法中使用以下代码加载资源：

ResManager.loadRes("data/res.json", function(){

//创建精灵

initRenderObj(scene);

//开始飞奔

g.run(-1);

});

注意，需要在和该html文件同一目录下创建一个data文件夹，然后定义一个res.json配置文件，代码如下：

{

"image":

[

{"name":"mario","src":"images/mr.png"},

{"name":"title", "src":"images/title.gif"}

],

"frame":

[

{"name":"sprite","src":"data/sprite_frames.json"}

]

}

然后在data文件夹中创建sprite_frames.json文件表示帧动画配置，代码如下：

{

"title":{

"type":0,

"img":"title"

},

"mario":{

"type":1,

"img":"mario",

"rc":[1, 15, 32, 32],

"animations":{

"run":[0, 2],

"jump":[6]

}

}

}

由于通过 loadRes 方法加载资源文件是异步的过程，所以，创建游戏精灵和启动游戏的方法必须放在loadRes的回调函数中处理。

另外，在给精灵设置动画的时候是通过以下代码实现的：

//获取玛丽动画序列资源

var anims = ResManager.getAnimationsByName("sprite", "mario");

mr.setAnims(anims, "run");

由于 ResManager 中保存所有的资源，所以只需要通过ResManager.getAnimationsByName 获取相关的资源，然后通过sprite的setAnims方法设置动画即可。

关于资源加载部分就差不多了，在实际应用中会碰到各种各样的自定义资源，这个时候就需要根据不同的情况写具体的资源加载器，并通过ResManager管理起来。

需要注意的是，如果是直接运行html文件，JS加载本地的数据文件有浏览器资源访问限制问题导致资源无法加载成功。如果是使用Chrome浏览器，需要在Chrome浏览器启动的快捷方式中加上--allow-file-access-from-files参数，然后通过该快捷方式启动浏览器，如图4-18所示。当然，如果是部署在服务器上则不会有问题，本书以后所有的例子，凡是涉及读取资源文件都会碰到此类问题，所以，请读者注意。

图4-18 Chrome 快捷方式参数

接下来，看看如何控制游戏中的人物，也就是游戏中的IO部分。

4.2.10 游戏IO 控制

在本小节中主要涉及游戏的IO控制，也就是常说的输入输出设备。实际上游戏的IO控制设备有很多种，比如键盘、鼠标、摇杆、触摸屏、方向盘、摄像头等，甚至还有体感设备，但在HTML5中键盘和鼠标比较常见，接下来把键盘和鼠标的功能集成到xengine中。

先来看看键盘的操作，通常情况下可以通过DOMElement的一系列的onKey事件获取键盘的KeyCode值来进行游戏的控制，但事实上无法获取任意几个组合键的键值。在很多游戏中会涉及自定义的组合键，也有可能多个玩家同时在一个键盘中操作，如果单纯通过 document 的Key事件处理是无法完成的，总之，我们必须要对键盘的操作做一些封装。

为了能够在游戏运行过程中知道究竟有多少个按键同时按下，通常情况下需要准备一个键盘缓冲区来保存每一个键的状态。由于键盘键位数量不会超过 255，所以通常这个缓冲区的大小为 255 比较合适，根据键盘码来存放每个按键的状态，如果是按下则该位置的值是 1，否则是0。比如键盘上A的键码是65，假设键盘缓冲区为states，当键A按下的时候则设置states[65] = 1，当键A弹起的时候则设置states[65] = 0。根据这个思想，我们初步可以得到以下代码：

//键盘类

win.Key = (function(){

var _K = {

A:65,

B:66,

C:67,

D:68,

E:69,

F:70,

G:71,

H:72,

I:73,

J:74,

K:75,

L:76,

M:77,

N:78,

O:79,

P:80,

Q:81,

R:82,

S:83,

T:84,

U:85,

V:86,

W:87,

X:88,

Y:89,

Z:90,

N0:48,

N1:49,

N2:50,

N3:51,

N4:52,

N5:53,

N6:54,

N7:55,

N8:56,

N9:57,

LEFT:37,

RIGHT:39,

UP:38,

DOWN:40,

ENTER:13,

SPACE:32,

TAB:9,

SHIFT:16,

ALT:18,

CTRL:17,

//记录键盘状态

states:new Array(255),

//设置是否可用

setEnabled:function(flag)

{

var self=this;

if(flag)

{

var st=this.states;

this.clearKeyStates();

document.onkeydown = function(e)

{

st[event.keyCode]=1;

}

document.onkeyup = function(e)

{

st[e.keyCode]=0;

}

}

else

{

document.onkeydown=null;

document.onkeyup = null;

}

},

//判断是否按键

pressed:function(key)

{

return this.states[key];

},

//复位所有状态

clearKeyStates:function()

{

for(var i=0;i<255;i++)

{

this.states[i]=0;

}

}

}

//初始化

_K.setEnabled(true);

return _K;

}());

以上代码提供了关于键盘的基本操作类，键盘类使用 states 数组保存每个按键的状态，在创建整个类后直接调用 setEnabled (true)方法绑定了 document 对象的 keydown 和 keyup 事件，然后根据键盘事件设置对应按键在states中的数值。在游戏中可以通过pressed方法判断是否按下了某一个键，比如，以下代码检测是否同时按下Ctrl键和A键：

if(Key.pressed(Key.CTRL)&&Key.pressed(Key.Z))

{

alert("同时按下了CTRL和A");

}

通常情况下，我们可以在游戏精灵的update方法中检测是否有按键响应用来处理精灵的移动，比如在SuperMario中可以通过按键控制人物移动、跳跃等。

事实上，关于键盘的处理模式有两种方式，一种称为立即模式，即按键了就立即处理事件，比如在游戏精灵的update方法中直接通过检测键盘操作来处理精灵移动这就是立即模式，采用这种方式处理的优势是比较简单快速，但缺陷是会引起键值的丢失，为什么呢？因为我们是在精灵的update方法中进行判断处理的，而update方法是通过setInterval或者setTimeout定时去调用，但是在两次update之间可能会存在多次按键操作，所以在update方法中检测会丢失这些操作。

另外一种键盘的处理模式是缓冲模式，即使用一个队列来保存每次按键的键值，然后在update 中所有按键出队列处理，这样就不会丢失按键盘操作，这样做带来的好出是不会丢失操作处理，但会带来额外的内存和性能消耗。

对于鼠标的操作同样也可以采用立即和缓冲模式，在本书的源码中，xengine 中的 xsys.js中包含了这两种模式的键盘和鼠标操作类。

基本上到此为止，一个游戏引擎的框架基本完成了，未来的路很长，但我们已经迈出了通往游戏世界有里程碑意义的一步，接下来我们可以开始创建一个相对完整的游戏来检测xengine了。

4.3 使用引擎构建游戏实例

本节是以 xengine 来完成一个经典的挡板球小游戏，屏幕中有很多砖块，还有一个四处移动的小球，小球碰到屏幕四周会反弹，碰到砖块会把砖块打掉，但如果到了屏幕底部则会丢失一条命，我们可以控制一个挡板防止小球落到底部，这就是一个简单的需求。对于初学者来说，需要开发各种小游戏来积累自己的经验，切勿以游戏小而不为。

案例：经典打砖块

前面介绍了下这个小游戏的简单需求，先来看看游戏运行的画面，如图4-19所示。

图4-19 打砖块游戏

在这部分中，会列出游戏实现的关键部分代码作为讲解，大家可以从本书附带代码中参考，相信我写的注释还是比较详细的。

整个游戏从Game类继承过来，命名为StickGame，游戏中涉及3个场景，游戏加载场景、游戏运行场景以及游戏结束场景，我们来逐步实现这3个场景。

首先是游戏加载场景，在这个场景中我们需要加载游戏运行的各种资源，然后通过进度条来显示当前加载的进度。

游戏中的资源配置文件主要包含资源配置res.json以及精灵定义 sprite_frames.json，这两个资源配置文件如何定义在加载游戏资源中已经做了详细讲解，不明白的可以参考前面资源的配置。

资源文件的配置定义好了之后，可以创建游戏加载场景，同时显示进度条，创建场景可以用以下方式：

//开始场景

var tSC = scm.createScene([{"x":x, "w":400, "h":500, "name":"title"}]);

如上所示，创建了一个名称叫title的场景，然后，通过以下方法，在这个场景中添加进度条以及显示加载字样。

//初始化title场景，添加加载进度条和提示

initTSC:function(sc)

{

//创建UI，创建加载进度条

var pBar=$("<div id='pCBar' style='position:absolute;border-radius:3px;border:

2px;solid;yellow;left:50px; top:245px;height:10px;width:300px;'></div>");

pBar.append("<div id='pBar' style='position:absolute;left:0px;top:0px;overflow:

hidden; background-color:red; width:1px;height:9px;top:1px;'></div>");

sc.holder.append("<div id='pTxt' style='text-align:center;position:absolute;font-size:

26px;color:white; height:40px;width:400px;top:210px;'>Loading...</div>");

sc.holder.append(pBar);

}

以上代码在title场景中创建了几个层来显示进度条和提示，这里使用了DOM元素作为游戏的UI，当然，也可以通过canvas来实现UI，只不过用canvas来实现UI需要自定义大量的UI组件和事件模型，如果我们结合canvas和DOM元素来构建游戏，这也不失为一个比较好的策略。

创建了 title 场景之后，我们可以通过 ResManager.loadRes 的回调函数来更新进度条， ResManager.loadRes 方法的第三个参数就是一个回调函数，用来渲染进度加载的。比如，我们可以使用以下代码渲染进度：

ResManager.loadRes("data/res.json", function(){

//加载游戏配置文件

ResUtil.loadFile("data/gamecfg.json", null, function(data){

self.cfg = data;

//显示主画面

self.showMain();

});

},

//进度条回调函数

function(total, cur){

//渲染进度条

var pro = (cur/total)*100|0;

$("#pTxt").text("Loading:"+pro+"%");

$("#pBar").width(pro*3);

});

在渲染进度条的回调函数中会接收 2 个参数，total 表示资源总数，cur表示当前已经加载的资源数，这样就可以根据这两个参数渲染进度了。

图4-20展示了场景title显示进度的画面。

图4-20 title场景加载

完成游戏加载场景后，接下来看看main场景，main场景主要显示挡板的生命数、分数以及当前游戏的等级，当然，还有游戏的各种精灵了。

创建游戏场景代码如下：

//游戏场景

var mSC = scm.createScene([{"color":"gray", "x":x, "w":400, "h":500, "name":"main"}]);

初始化main场景UI，同样，我们使用DOM作为main场景的UI：

//初始化main场景

initMSC:function(sc)

{

//创建UI

sc.holder.append("<div id='pLifeTxt'

style='text-align:left;position:absolute;font-size: 26px;color:red;height:40px;

width:130px; left:10px'> Life:0 </div>");

sc.holder.append("<div id='pLevTxt'

style='text-align:left;position:absolute;font-size: 26px;color:red;height:40px;

width:130px;left:150px'>Level:1 </div>");

sc.holder.append("<div id='pScTxt'

style='text-align:left;position:absolute;font-size: 26px;color:red;height:40px;

width:130px;left:290px'>Score:000 </div>");

}

在整个游戏运行过程当中，需要根据游戏运行的状态来更改这些游戏UI。一般来说，我们需要在游戏每帧运行的最后绘制游戏的UI，还记得我们的第一个demo来显示游戏运行的帧数吗？对了，我们可以把更新UI的步骤交给游戏监听器去处理，在下列初始化游戏监听器中加入了UI更新操作：

//增加游戏监听器

initListener:function()

{

var self=this;

//增加监听器

var ltn = new AppEventListener({

"afterRender":function(){

//更新UI

self.updateUI();

}

}

});

this.addListener(ltn);

},

方法updateUI主要用于更新游戏运行场景的UI，比如更新生命可以使用以下方法：

//更新生命

updateLife:function()

{

$("#pLifeTxt").text("Life:"+this.cfg.life);

}

好了，main场景部分的UI已经有了，接下来就是几个主角的出场了，分别是挡板类、小球类以及砖块类。毫无疑问，这3个类最好都继承自Sprite类，在Stick挡板对象中，主要涉及玩家需要使用键盘操作挡板以挡住小球，以免让小球落下，来看看Stick挡板的实现：

(function(){

//从引擎的RenderObj继承

Stick = Sprite.extend({

//重写update

update:function()

{

//获取scene边界

var w = this.owner.w,

hw = this.w*0.5;

//控制按键

if(Key.pressed(Key.SPACE))

{

if(StickGame.state==0)

{

StickGame.launchBall();

}

}

if(Key.pressed(Key.A))

{

if(this.x>hw)

{

this.move(-5, 0);

}

}

if(Key.pressed(Key.D))

{

if(this.x<(w-hw))

{

this.move(5, 0);

}

}

//检测碰撞球，如果碰撞到则触发事件

var ball = StickGame.ball,

bsx = this.x-this.w*0.5-ball.r,

bex = bsx+this.w+ball.r,

bsy = this.y-this.h*0.5-ball.r,

bey = this.y+this.h*0.5+ball.r;

if(ball.x>=bsx&&ball.x<=bex&&ball.y>=bsy&&ball.y<=bey)

{

StickGame.fireEvent(newEvent(this,StickGame,StickGame.doStickCollide));

}

}

});

Stick.ClassName = "Stick";

//注册Ball类

ClassFactory.regClass(Stick.ClassName, Stick);

}())

Stick主要重写了Sprite的update方法以适应本游戏的需求，这个方法中主要涉及对Stick对象的控制，以及小球碰撞到Stick时的处理。

可以通过Key.pressed方法判断用户的按键信息，当用户按下A、D键时候，修改Stick的位置，如果游戏的状态是0，表示游戏处于就绪状态，这时，小球停留在挡板上，如果按下Space键，则游戏开始，小球弹出。

在update方法中另外一个需要处理的是检测小球和Stick的碰撞，这里碰撞处理的比较简单。实际上我们可以把小球和挡板用两个矩形围起来，只要检测这两个矩形是否碰撞即可。这还是有些麻烦，但我们可以很简单地判断一个矩形是否在另一个矩形内部。如图4-20所示，小矩形的坐标为(x, y)，则如果x 坐标在大矩形左右边坐标之间，同时 y 坐标在大矩形上下坐标之间，则小矩形完全在大矩形内部，那么假设把大矩形的左右外围扩大半个小矩形的宽度，而上下外围扩大半个小矩形的高度，则就可以表示刚好两个矩形相撞。

图4-21 矩形碰撞

游戏中的精灵碰撞检测，在后面的游戏算法章节中会有更加详细的关于碰撞检测的算法。

最后，如果如果小球碰撞到了Stick，则触发一个事件，Event是xevent.js中定义的一个简单的事件对象，代码如下：

//简单的事件对象

var _event = win.Event = Class.extend({

init:function(src, obj, method)

{

this.src=src;

this.obj = obj;

this.method = method;

},

exec:function()

{

this.method&&this.method.call(this.obj, this.src);

}

})

创建Event对象需要3个参数，src表示触发事件的事件源，obj表示将会处理这个事件的对象，method表示处理这个事件对象的方法，Event中的exec方法实际上是调用事件处理对象的方法来处理事件。

根据以上 Event 的描述，可以得知，当这个碰撞事件触发后，将会调用 StickGame 的doStickCollide方法，doStickCollide方法的实现如下：

//处理碰撞挡板事件

doStickCollide:function(sender)

{

//小球反弹

this.ball.dy = -this.ball.dy;

this.ball.dx+=MathUtil.randInt(-1, 2);

}

当小球碰撞到Stick的时候，我们需要做的事情就是更改小球y方向的速度，调整为反向， x 方向可以加一个随机的扰动值，这样就完成了小球的反弹，在小球和砖块的碰撞中也是类似处理。

小球Ball的实现类和Stick一样继承自Sprite，实现如下：

(function(){

//从引擎的RenderObj继承

Ball = Sprite.extend({

init:function(name, r)

{

//定义半径

this.r = r||10;

this._super(name);

this.w = this.h = this.r*2;

},

//重写update

update:function()

{

var w=this.owner.w,

h = this.owner.h;

//如果游戏处于就绪状态，小球跟随挡板移动

if(StickGame.state==0)

{

StickGame.resetBall();

}

//到达边界改变速度方向

if(this.x<this.r||this.x>w-this.r)

{

this.dx=-this.dx;

};

if(this.y<this.r)

{

this.dy=-this.dy;

}

if(this.y>StickGame.stick.y)

{

StickGame.fireEvent(new Event(this, StickGame, StickGame.doLose));

}

//调用父类方法

this._super();

}

});

Ball.ClassName = "Ball";

//注册Ball类

ClassFactory.regClass(Ball.ClassName, Ball);

}())

Ball的实现类也只是重写了Sprite的update方法，这个方法和之前小球屏幕弹球类似，区别在于，如果小球落到了挡板以下的位置，则触发一个接球失败的方法，要求游戏处理游戏失败的逻辑。

最后一个精灵对象是Block砖块类，实现代码如下：

(function(){

//从引擎的RenderObj继承

Block = Sprite.extend({

init:function(name)

{

//定义级别

this.lev = 1;

//被击中次数

this.hit = 0;

this._super(name);

},

update:function()

{

//检测是否碰撞到，如果碰撞触发事件

var ball = StickGame.ball,

bsx = this.x-this.w*0.5-ball.r,

bex = bsx+this.w+ball.r,

bsy = this.y-this.h*0.5-ball.r,

bey = this.y+this.h*0.5+ball.r;

if(ball.x>=bsx&&ball.x<=bex&&ball.y>=bsy&&ball.y<=bey)

{

++this.hit;

StickGame.fireEvent(new Event(this, StickGame, StickGame.doBlockCollide));

}

},

//更新动画

updateAnim:function()

{

this.setCAnim("s"+(this.lev-this.hit));

},

//判断是否可以销毁

canRelease:function()

{

return (this.hit==this.lev);

},

//获取分数

getScore:function(lev)

{

return this.lev*10;

}

});

Block.ClassName = "Block";

//注册Ball类

ClassFactory.regClass(Block.ClassName, Block);

}())

砖块的update方法中一样，对砖块和小球的碰撞做了检测，然后触发砖块碰撞事件，同时，根据游戏业务的逻辑需要提供了几个基本属性和方法。lev属性定义了级别，表示当前的砖块等级，hit记录了砖块碰撞的次数，canRelease方法设定了如果小球的级别和碰撞次数相等，则该对象是可以通过Scene的removeRObj方法把对象移除掉，updateAnim方法则是根据小球碰撞的次数修改当前砖块的显示样式，getScore方法则提供了当前小球的分值。

关于main场景的几个主要的精灵对象就完成了，接下来看看关于StickGame类的处理。

为了便于游戏的扩展和修改，我们可以把游戏中需要的数据比如游戏的生命、分数、关卡数据，使用配置文件管理起来，以下gamecfg.json描述了整个游戏需要的配置。

{

"stick":{"w":100,"h":20,"resName":"sprite","fName":"stick"},

"ball":{"r":8, "resName":"sprite", "fName":"ball"},

"block":{"w":80, "h":20, "resName":"sprite", "fName":"block"},

"blockNum":0,

"life":3,

"maxLev":2,

"level":1,

"score":0,

"lev1":[

[1,1,1,1,1],

[1,0,1,0,1],

[1,0,1,0,1],

[1,1,1,1,1],

[1,1,1,1,1],

[0,1,1,1,0]

],

"lev2":[

[0,2,2,2,0],

[2,1,1,1,2],

[2,1,3,1,2],

[2,1,1,1,2],

[2,2,2,2,2],

[0,3,3,3,0]

]

}

我们使用配置定义游戏中各个对象的初始化参数以及各种数据，比如：

"stick":{"w":100, "h":20, "resName":"sprite", "fName":"stick"},

定义了挡板的宽度、高度及使用的资源名称， StickGame 有一个 cfg 属性，可以把gamecfg.json加载后传递给这个属性，这样，我们在游戏中就可以根据配置创建对象了。以stick为例，以下方法根据配置创建了挡板对象：

//根据配置数据创建挡板

function createStick(sc)

{

var cfg=self.cfg.stick;

var st = sc.createRObj(Stick.ClassName, ["stick"]);

st.w = cfg.w;

st.h = cfg.h;

var mx = sc.w*0.5;

st.moveTo(mx, sc.h-30);

//获取挡板动画序列资源

var anims = ResManager.getAnimationsByName(cfg.resName, cfg.fName);

st.setAnims(anims);

self.stick = st;

}

同样的道理，可以根据ball和block的配置信息创建小球和砖块。

对于砖块的创建比较特殊，因为根据游戏关卡的不同，砖块的排列也有所不同。对于一些较大类型的游戏来说，有一个场景编辑工具来编辑场景中的各种元素会比较方便，但工具完成起来非一日之功。

这里我们使用一个二维数组来描述砖块的排列场景，如图4-22所示，我们可以把整个屏幕划分成一个一个的小格子，然后在有砖块的地方用1或者其他数字表示，没有的地方用0表示，这样，我们就可以循环这个二维数组来根据数组的规律产生砖块了。当然，我们必须要定义好屏幕的大小和砖块的大小，以便于砖块的排列分布比较均匀，在配置文件中使用lev的数组来定义砖块排列数据，其中数字表示砖块的级别。

图4-22 用二维数组划分砖块地图

下面的代码根据配置文件创建了砖块：

function createBlock(sc)

{

//获取当前级别

var lev = self.cfg.level,

cfg = self.cfg["lev"+lev],

bcfg = self.cfg.block;

//获取砖块动画序列资源

var anims = ResManager.getAnimationsByName(bcfg.resName, bcfg.fName);

var bOffY = 60;

for(var i=0;i<cfg.length;i++)

{

for(var j=0;j<cfg[i].length;j++)

{

var bData = cfg[i][j];

if(bData>0)

{

var bk = sc.createRObj(Block.ClassName);

bk.setAnims(anims, "s"+bData);

bk.lev = bData;

bk.w = bcfg.w;

bk.h = bcfg.h;

bk.moveTo(j*bk.w+(bk.w*0.5), bOffY+i*bk.h);

++self.cfg.blockNum;

}

}

}

}

定义好了砖块大小和砖块排列的数组之后，最终就可以形成砖块的地图场景了。

最后，第三个场景是end场景，end场景比较简单，产生end场景的UI代码如下：

//初始化结束场景

initESC:function(sc)

{

//创建UI

sc.holder.append("<div id='pEndTxt' style='text-align:center;font-size:35px;

position:absolute; border-radius:3px;color:white; left:50px;top:245px;height:10px;

width:300px;'>Game Over</div>");

}

这里只是创建了一个简单的文字层，通过该层显示结束信息，如果用户2个关卡毫无障碍的过关则显示"Mission Complete!"，否则显示"GameOver"。相信，这比起写代码来还是轻松得多吧。

需要注意的是，可以通过场景管理器SceneManager的bringToTop方法把指定的场景移动到最前端，让用户看到，3 个场景可以根据游戏的逻辑进行切换。比如加载游戏场景结束后可以切换到main场景，玩家在游戏失败或成功后切换到end场景。好了，关于本游戏的核心部分介绍到这里，完整的代码可以参考本书的附带代码。

4.4 小结

好了，以一个经典的弹球小游戏作为本章的一个结束，在本章中我们了解了一个游戏引擎的制作过程，同时也通过弹球小游戏掌握了如何使用这个简单但基本完善的引擎去制作第一款游戏，希望大家通过这一章的学习能体会到游戏制作的基本流程。

这款引擎虽然制作出来了，但可完善的地方还很多，以下提供关于xengine的几点改进建议。

（1）JS的文件依赖性比较强，在html中引入js文件必须按照一定的顺序导入，关于这个问题，可以使用JavaScript的required框架管理js文件的依赖关系。

（2）引擎中的Scene类中还可以更进一步划分层，也就是说实际上场景中的元素还可以根据需要划分到不同的层次中，为了提高渲染效率，有些层是不需要刷新的，比如背景层。这样通过层之间的叠加可以根据需要进行渲染达到比较高的效率。

（3）采用WebGL方式进行渲染将大大提高效率，虽然使用canvas元素的环境上下文对图像提供了一些基本操作，但对于有些游戏来说还是不够的，如果能通过 WebGL 直接访问显卡硬件的方式将会带来更高的效率，这将涉及浏览器的兼容性问题以及采用3D的方式来渲染2D画面。

（4）对于场景元素同样可以采用配置的方式描述，即用配置文件描述场景，在游戏中通过加载场景配置文件的方式创建场景。

第5章 网络通信基础

HTML5 提供了强大的通信功能Web sockets 技术，弥补了传统的Web 应用程序缺乏实时通信功能的缺陷，使用Web sockets 技术可以在Web 应用程序中实现类似于传统的C/S 结构应用程序的通信功能，使得在Web环境中构建实时的通信应用和网络游戏提供了可能。

本章主要涉及以下内容：

• 通信基本知识；

• 服务器端通信框架Node.js 基础；

• Web sockets 基础；

• socket.io.js 框架基础。

5.1 网络协议基础

5.1.1 通信协议

正如人和人之间的沟通需要通过语言一样，计算机之间或者网络设备之间相互的通信也需要自己的语言，可以把这种符合网络设备之间通信的语言规则（或标准）称为网络传输协议或者通信协议，通信的双方确定了通信协议后，就可以进行正常的沟通了。

常见的协议有TCP/IP、UDP、HTTP、FTP、SMTP等，结合本章的内容，主要介绍TCP/IP和HTTP。

5.1.2 TCP/IP

TCP/IP [1]是目前应用最为广泛的协议，是构成Internet国际互联网协议的最为基础的协议，正是有了 TCP/IP 为基础，我们才可以有现在丰富多彩的互联网世界。TCP/IP 定义了网络设备如何连入因特网，以及数据如何在它们之间传输的标准。实际上 TCP/IP 有两个协议组成，分别是TCP（Transmission Control Protocal，传输控制协议）和IP（Internet Protocol，网际协议），TCP负责数据的可靠性传输的问题，一有问题就发出信号，要求重新传输，直到所有数据安全正确地传输到目的地，而IP主要负责给每台网络设备一个网络地址，保证数据传输到正确的目的地。

理论上讲，由于通信网络过于复杂，仅仅依靠 TCP/IP 两个协议是远远不够的，所以通常我们所说的TCP/IP实际上是对TCP/IP协议栈的一个简化说法，TCP/IP协议栈包含了以TCP/IP为基础的一系列的协议，为了完成整个网络通信的任务，会根据不同的功能进行区域的划分，形成一个具有层次结构的层，这些层就构成了协议栈。

TCP/IP的协议栈是由4个层次组成，从底层到高层分别是：网络接口层、网络层、传输层、应用层，每一层只和相邻的上下两层打交道，当数据在网络中传输的时候高层会根据自己的需要向底层网络获取数据。下面来看看每个层次的具体作用以及在该层次中常用的协议。

• 网络接口层：又称为网络链路层，实际上这一层可以细分为物理层和数据链路层，物理层主要包含携带网络数据的传输媒体介质，这种介质通常是双绞线、同轴电缆或标准串口线等；而数据链路层主要负责加强物理层传输的数据，把数据传递给网络层，包含的协议主要有ARP（地址转换协议）和RARP（反向地址转换协议）。

• 网络层：网络层的目的是实现两个端系统之间的数据透明传送，具体功能包括寻址和路由选择、连接的建立、保持和终止等，这一层的协议主要包括IP、ICMP（互联网控制消息协议）和IGMP（因特网组管理协议）。

• 传输层：传输层主要负责解决端对端的数据可靠性传输的问题，保证数据完整、可靠的，并且按照顺序的达到对方，这一层的协议主要包扩 TCP（传输控制协议）和UDP（用户数据报协议） [2]。

• 应用层：主要用于向用户提供一组常用的应用程序，如电子邮件、文件传输访问、远程登录等。这一层的常见协议比较多，如HTTP、FTP、DNS、SMTP等。

5.1.3 HTTP

HTTP是在TCP/IP基础上应用的最成功的多媒体协议，正是有了HTTP的出现才有了互联网蓬勃的发展，有了HTTP，互联网才会变得如此精彩。

HTTP 的发展是万维网联盟（World Wide Web Consortium）和 Internet 工程任务组小组（Internet Engineering Task Force）合作的结果，（他们）最终发布了一系列的RFC，其中最著名的就是RFC 2616。RFC 2616 定义了HTTP 中一个现今被广泛使用的版本——HTTP 1.1。

最初定义HTTP的目的是为了提供一种发布和接收HTML页面的方式，该方式中定义了客户端的请求和服务器端应答的标准。客户端通过使用Web浏览器发出一个到服务器上指定端口（默认端口为80）的HTTP请求，服务器根据客户端的请求把服务器上存储着的一些资源发送给客户端，HTTP 响应请求如图5-1所示。

图5-1 HTTP 协议请求响应

HTTP有如下特点。

（1）支持客户/服务器模式。

（2）简单快速：客户向服务器请求服务时，只需传送请求方法和路径。

（3）灵活：HTTP允许传输任意类型的数据对象。正在传输的类型由Content-Type加以标记。

（4）无连接：无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求，并收到客户的应答后，即断开连接。采用这种方式可以节省传输时间，提高服务器的大并发量的请求能力。

（5）无状态：HTTP是无状态协议。无状态是指协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息，则它必须重传，这样可能导致每次连接传送的数据量增大。

我们能通过浏览器浏览各种网站的信息实际上就是HTML文档通过这种HTTP，在网络上传输的结果，当我们通过浏览器在网址上输入地址（如http://www.163.com）后回车，就开始了一次HTTP请求传输的过程，其间经历了以下几个步骤。

（1）客户端浏览器获取Web服务器IP地址，建立TCP连接。

（2）客户端浏览器向Web服务器发出HTTP请求。

（3）Web服务器应答，并向客户端传递响应数据。

（4）Web服务器断开和客户端的TCP连接。

在HTTP1.1协议中一共定义了以下8种类型的请求方式。

• GET：向特定的资源发出请求获取。

• POST：向指定资源提交数据进行处理请求（如提交表单或者上传文件）。数据被包含在请求体中。POST请求可能会导致新的资源的建立和/或已有资源的修改。

• OPTIONS：返回服务器针对特定资源所支持的HTTP 请求方法。

• HEAD：向服务器索要与 GET 请求相一致的响应，只不过响应体将不会被返回。这一方法可以在不必传输整个响应内容的情况下，就可以获取包含在响应消息头中的元信息。

• PUT：向指定资源位置上传其最新内容。

• DELETE：请求服务器删除Request-URI所标识的资源。

• TRACE：回显服务器收到的请求，主要用于测试或诊断。

• CONNECT：HTTP 1.1协议中预留给能够将连接改为管道方式的代理服务器。

其中GET和POST请求是我们最常使用的请求，浏览器发出请求时，会把请求的数据按一定格式组合起来，请求由3部分组成，分别是请求行、消息报头和请求正文。GET请求如下：

GET / HTTP/1.1

Accept: */*

Accept-Language: zh-cn

Accept-Encoding: gzip, deflate

User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR

2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)

Host: www.163.com

Connection: Keep-Alive

第一行是一个请求行，包含了请求的方法、采用的协议以及请求的资源。

接下来的信息是请求报头，由一系列的报头组成，头部信息之后必须有一个空行用来和请求的数据区分开，最后包含了请求的正文数据。

响应部分也包含了3个部分，分别是状态行、消息报头和响应正文。例如：

HTTP/1.1 200 OK

Date: Fri, 22 May 2009 06:07:21 GMT

Content-Type: text/html; charset=UTF-8

<html>

<head></head>

<body>

<!--body goes here-->

</body>

</html>

第一行是一个状态行，包含了响应的状态，接着的是各种响应头信息，响应头之后是空行，用于区分响应的数据，HTML的部分就是响应的正文数据。

5.1.4 套接字

前面说到了几种简单的协议，让我们知道网络通信协议本质上就是为了让多个网络设备交互数据而制定的规则和约定。有了规则还不够，交流的时候还需要把符合这些规则的数据发给对方。打个简单的比方，就好像两个人对话的时候，双方都知道语言的语法结构，但是两个人对话还得通过嘴巴。简单地说，套接字就类似于嘴巴的功能，用于实现这些协议的应用程序接口，从而进行网络数据传输。

套接字（socket）这个概念最早来源于 UNIX 系统，由加利福尼亚的伯克利大学开发，所以有时候也称为BSD Socket，即伯克利套接字接口。使用这个接口，可以使主机间或者一台计算机上的进程间可以通信。它可以在很多不同的输入/输出设备和驱动之上运行，这有赖于操作系统的具体实现。该接口实现用于TCP/IP，因此它是维持Internet的基本技术之一。如今，所有的现代操作系统都有一些源于伯克利套接字接口的实现，如Windows下面的WinSocket。

套接字的类型分为三种，即流套接字、数据包套接字和原始套接字。

• 流套接字（SOCK_STREAM）：流套接字用于提供面向连接的、可靠的数据传输服务。该服务将保证数据能够实现无差错、无重复发送，并按顺序接收。流套接字之所以能够实现可靠的数据服务，原因在于其使用了传输控制协议，即TCP。

• 数据包套接字（SOCK_DGRAM）：数据包套接字提供了一种无连接的服务。该服务并不能保证数据传输的可靠性，数据有可能在传输过程中丢失或出现数据重复，且无法保证顺序地接收到数据。数据包套接字使用UDP（User Datagram Protocol，用户数据报协议）进行数据的传输。由于数据包套接字不能保证数据传输的可靠性，对于有可能出现的数据丢失情况，需要在程序中做相应的处理。

• 原始套接字（SOCK_RAW）：原始套接字与标准套接字（标准套接字指的是前面介绍的流套接字和数据包套接字）的区别在于：原始套接字可以读写内核没有处理的IP数据包，而流套接字只能读取TCP的数据，数据包套接字只能读取UDP的数据。因此，如果要访问其他协议发送的数据必须使用原始套接字。

在套接字的使用过程中，通常情况下会分为在服务器端使用的套接字和在客户端的使用的套接字。

图5-2描述出了服务器端和客户端如何使用套接字进行通信的，这种通信模型独立于具体的操作系统和语言的实现，不管是在 Windows 还是Unix或者Linux下面都是采用图5-2所示的通信模型。在本节后面我们会用具体的例子实现这种通信模式，接下来我们看看目前 Web 端常用的服务器端的框架Node.js。

图5-2 服务器端和客户端套接字通信模型

5.2 服务器端框架Node.js基础

5.2.1 简介

在以前我们可能见过各种各样的使用不同语言开发的服务器，比如，使用Java语言开发的Web服务器Tomcat、JBoss，使用C、C++或者Erlang开发的各种游戏服务器等，现在我们也可以使用JavaScript语言来进行服务器的开发。

Node.js是一个可以使用JavaScript语言进行服务器端开发的一个平台，在Node.js的官方介绍中，是这样来描述Node.js的：Node.js是建立在Chrome浏览器的JavaScript运行时基础上，用于快速构建可扩展的网络应用的平台。Node.js使用事件驱动，非阻塞I/O模型，使得它轻量高效，可完美地用于分布式环境下的数据密集型、实时应用程序。由此可见，Node.js本身是为创建高效的网络应用程序而生的。我们知道，JavaScript是一种开发客户端应用的，运行在浏览器中的语言，如今，借助于Node.js这样一个平台，使用JavaScript可以开发出后台服务器端的应用。本质上来说，Node.js是由C++语言编写的，运行在Google开发的V8 [3]引擎上的一个平台，拿java做比方，JavaScript的地位就相当于Java，Node.js平台相当于Java虚拟机JVM，我们可以使用JavaScript语言来编写各种服务器端的应用程序，然后在Node.js这个平台上面运行代码。

需要注意的是，虽然JavaScript可以运行在Node.js平台上，但是由于Node.js是一个独立运行的环境，不是浏览器环境，所以在Node.js上运行的JavaScript是不能够使用DOM（文档对象模型）和BOM（浏览器对象模型）元素的。

5.2.2 安装

可以在Node.js的官方网站http://nodejs.org/download/直接下载适合各个平台的Node.js，写本书的时候，最新的版本是v0.8.9版本。为了快速掌握Node.js，我们只介绍Windows下面的安装方式，Windows下的安装比较简单，直接在下载页面下载node-v0.8.9-x86.msi文件，双击后就直接运行，运行后会将node.exe文件安装到C:\Program Files (x86)\nodejs\目录下，并将该目录添加进PATH环境变量，我们就可以直接在命令行下输入node.exe运行使用JavaScript编写的代码。

安装好之后，可以在命令行下，通过下列命令检查当前的Node.js版本：

node -v

接下来，试着建立一个js文件，比如hello.js，在文件中输入以下代码：

console.log("Hello, Node.js!")

然后，在命令行方式下输入:

node hello.js

如果没出什么意外，可以在控制台看到输出文字"Hello, Node.js!"，hello.js文件是标准的JavaScript代码，console.log用于在控制台输出文字，注意，不能使用document.write或者alert输出，因为Node.js平台不是浏览器，不能运行基于DOM和BOM元素的操作。

其他操作系统可以在分布式版本控制系统平台 GitHub [4]进行下载安装，具体请参考https://github.com/joyent/node/wiki/Installing- Node.js-via-package-manager。

5.2.3 应用

安装完Node.js后，就可以直接使用Node.js平台。先来看一个简单的例子，使用文本编辑软件写一个demo1.js文件，代码如下：

//导入http模块

var http = require("http");

//创建Web服务器

var server = http.createServer(function(req, res){

//写响应头

res.writeHead(200, {'Content-Type':'text/plain'});

//输入文字

res.write("Hello, Node.js!");

//结束

res.end();

});

//启动监听

server.listen(8888, "localhost");

创建代码完毕后，在demo1.js目录下使用命令行输入以下命令：

node demo1.js

上面的demo1.js文件就是一个超级简单的Web服务器，可以通过任意一个浏览器访问该服务器，输入地址http://localhost:8888，这样，我们就可以看到浏览器中显示了文字"Hello, Node.js!"。

接下来，让我们分析一下这个HTTP服务器的构成。

首先我们通过require方法导入http模块，并且把它赋值给http变量。在Node.js中有很多模块，我们可以通过require的方式引用这些模块，一旦引用这些模块后，就可以把该模块赋值给一个变量。通过这个变量就可以使用这个模块中提供的所有功能，这种引用的方式类似于Java中的import或者C++中的#include方式，由于我们这次需要创建http服务器，就需要导入Node.js中的http模块。

接下来我们调用http 模块提供的函数：createServer 。这个函数会返回一个Web Server 对象。该函数的原型如下：

http.createServer([requestListener])

其中的requestListener是一个处理请求监听函数，该监听函数用于处理用户的请求和响应，比如我们可以通过下面代码创建服务器:

var server=http.createServer(function(req, res){

//处理用户请求响应

})

一旦有客户端通过浏览器请求服务器，服务器的 requestListener 函数就会执行，该函数有两个参数，分别是SeverRequest对象和ServerResponse对象。ServerRequest封装用户请求信息， ServerResponse 封装响应信息。在本例子中使用了 ServerResponse的两个输出方法，writeHead和write, writeHead 用于输出HTTP响应头，write 用于输出文本，关于这两个对象的其他详细方法可以参考Node.js的API文档（http://nodejs.org/api/http.html）。

创建好服务器之后，我们需要做的工作就就是启动服务器的监听方法listen，监听是否有客户端需要提供服务。一旦服务器处于监听状态，只要有客户端连接到该服务器，服务器就可以调用处理请求监听函数响应客户请求，listen方法的原型如下：

server.listen(port, [hostname], [backlog], [callback])

其中，port是需要监听的端口号，必填项目，hostname是监听的主机名，通常是本机，backlog是等待队列中的最大连接数，默认值是511，最后一个是异步的回调函数，用于处理监听事件。

5.2.4 案例：简单的Web 服务器

以上的代码只是简单的输出文字到客户端浏览器，现在我们可以做一个简单的 Web 服务器。简单地说，一个Web服务器最基本的功能就是能够根据客户端浏览器提出的http请求进行响应。所谓响应，就是把用户所请求的服务器资源传递给客户端浏览器，比如，客户端在浏览器中输入http://www.163.com/index.html后，网易的Web服务器就会把服务器本地的index.html文件传递给客户端的浏览器。为了完成这个功能，至少，服务器能够读取本地文件和传输文件， Node.js 的file 模块正好提供了文件的操作，要使用Node.js 的文件模块，需要使用require ("fs")导入文件模块，现在看看file模块的几个基本函数。

1．readFile(filename, [encoding], [callback])

异步读取整个文件的内容。

• filename：需要读取的文件。

• encoding：读取文件的编码格式，如果忽略，则返回数据缓冲对象，否则返回字符串。

• callback：读取文件后的回调函数，该函数有两个参数，一个是err 错误对象，返回读取文件时发生的错误，第二个是data对象，返回文件内容。

比如，以下代码就可以读取index.html文件内容:

var fs = require("fs")

fs.readFile("index.htm", function(err, data){

if(err)throw err

else

console.log(data);

})

2．exists (path, [callback])

异步测试给定的路径或文件是否存在。

• path：给定的文件路径或文件。

• callback：回调函数，callback有一个参数，返回true或者false，表示给定的path是否存在。

例如：

var fs = require("fs")

fs.exists("index.htm", function(exists)

{

if(exists)console.log("file exists!");

else console.log("file not found!");

});

现在我们可以完成一个简单的HTTP服务器了，创建一个server.js文件，输入代码如下：

var http = require("http"),

fs = require("fs"),

url = require("url");

//定义HttpServer对象

var HttpServer = function(port)

{

//由http模块创建的server对象

this.server = http.createServer(doRequest);

//服务器绑定的端口号

this.port = port;

//加载静态资源

function loadStaticResource(request, response)

{

//通过url解析请求中的url对象

var pURL = url.parse(request.url);

var fName = _ _dirname+pURL.pathname;

fs.exists(fName, function(exists){

if(exists)

{

fs.readFile(fName, function(err, data){

if(err)

{

throw err;

}

else

{

//设置mime内容类型

var contentType = "text/html";

response.writeHead(200, {"Content-type":contentType});

response.write(data);

response.end();

}

});

}

else

{

response.end("404 not found!");

}

})

}

//响应静态请求

function doRequest(request, response){

loadStaticResource(request, response);

};

}

//设定HttpServer对象的start方法

HttpServer.prototype.start=function()

{

this.server.listen(this.port);

console.log("listen:"+this.port);

}

//创建HttpServer

var server = new HttpServer(9001);

//启动server

server.start();

代码首先导入3个常用的Node.js模块，即http、fs和url模块，http模块主要提供http服务相关的功能，fs提供文件操作的功能，url提供了对url的解析功能。导入所需的模块后，定义了一个HttpServer的函数对象，在该对象定义了两个属性，一个是server，用于指向通过http创建的服务器，一个是port，用于绑定监听的端口。在createServer的时候，把requestListen函数指向了 doRequest 方法，在 doRequest 方法中调用了 loadStaticResource 方法，在loadStaticResource方法中做了这样几个操作：首先，通过url.parse方法解析request中的url对象，解析后，可以通过url.pathname 获取客户端请求的文件名。然后，使用全局变量_ _dirname+ url.pathname，获取请求文件在服务器上的绝对路径，_ _dirname 是 Node.js 中的全局变量，通过它可以得到当前执行脚本所在的路径。最后，调用 fs.exists 方法检查用户请求的文件是否存在，如果存在，则读取该文件，通过response的write方法输出所有的文件内容到客户端浏览器中，否则，输出提示信息"404 not found!"。

到此，一个简单的HTTP服务器就完成，我们可以在这个js文件的同级目录放置html文件，比如index.htm，然后通过node server.js启动服务器，在浏览器中输入http://localhost:9001/index.htm 访问资源。当然，这只是一个最简单的 HttpServer，离生产环境的 HttpServer 还有很长的距离，不过通过这个简短的代码，可以让我们领略到 Node.js 的魅力，这只是 Node.js的http服务器模块，我们还可以通过Node.js的其他模块，比如net和udp模块完成 TCP/IP的实时双向通信功能。以上是关于服务器端的功能，下面来看看 HTML5 中提供的客户端套接字功能。

5.3 Web sockets 基础

5.3.1 简介

Web sockets是HTML5 开始提供的一种浏览器与服务器间进行全双工通信的网络技术。依靠这种技术可以实现客户端和服务器端的长连接，双向实时通信。其实，Web sockets就是客户端的套接字，之所以需要使用Web sockets，是因为，在目前的Web 方式下实现即时通信，所用的技术都是轮询，除非使用Flash或者ActiveX插件来完成。轮询是在特定的的时间间隔（如每1秒钟），由浏览器对服务器发出HTTP请求，然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点，即浏览器需要不断的向服务器发出请求，每次向服务器端请求数据的时候都必须发送请求头，频繁的发送数据包会占用大量的带宽和服务器资源。

为了更好地达到实时通信的效果，降低客户端频繁的请求服务器，并且能够使得服务器能够主动的发送数据到客户端，Web sockets 这种高效的实时通信技术就应时而生。有了 Websockets的技术支持，Web应用程序终于赶上了C/S结构应用程序在通信领域上的步伐，虽然有限，但对于Web开发来说还是具有非常重大的意义，为建立实时的网络应用和游戏提供了可能。

5.3.2 应用

本质上来说，Web sockets是客户端的套接字，所以在使用上仍然遵循图5-2的客户端使用方式，即，首先连接服务器，然后可以向服务器发送数据，同时也可以接收服务器发送的消息，使用完毕后需要关闭和服务器的连接。

Web sockets使用上比较简单，比如，可以通过下列代码建立一个服务器端的连接：

var client = new WebSocket("ws://localhost:9000/socket")

可以通过new WebSocket (url)的方式创建客户端套接字对象，URL 必须以ws 或者wss 开头，表示采用WebSocket协义或者WebSocket安全协议，就像我们访问网址需要通过HTTP，需要使用http开头一样。假设有一个服务器端在本地的9000端口监听，那么，在创建WebSocket的时候，就可以通过ws://localhost:9000/socket的方式连接到该服务器，一旦服务器连接成功，就可以进行双向通信了。

为了能够能够随时知道WebSocket对象的状态，可以通过readyState获取WebSocket状态信息，readyState状态通过以下数字常量表示。

• 0：CONNECTING，表示正在连接。

• 1：OPEN，表示已经建立连接。

• 2：CLOSING，表示正在关闭连接。

• 3：CLOSED，表示已经关闭连接。

建立好了和服务器的连接之后，可以通过send方法，发送数据到服务器，目前，WebSocket中可以使用的方法只有send，send方法原型如下：

void send(data)

data是需要发送的数据，通常情况下是一个字符串，也可以是二进制数据。

除了可以发送数据到服务器，还可以响应服务器的消息，响应服务器的消息采用的是事件的方式，WebSocket支持的事件如表5-1所示。

表5-1 WebSocket 事件

比如，以下代码展示了和服务器的一个简单的通信：

<body>

状态:<span id="st"></span><br>

<input id="host">

<input type="button" id="btnConn" value="连接服务器" /><br>

</body>

<script src="js/jquery.js"></script>

<script>

var client = null,

staMsg = ["正在连接服务器……", "建立连接成功!", "正在关闭连接……", "已经关闭连接!"];

//初始化

$(document).ready(function(){

//绑定建立连接

$("#btnConn").click(function(){

connect();

});

})

//显示套接字消息

function dispStatus()

{

$("#st").text(staMsg[client.readyState]);

}

//连接服务器

function connect()

{

client = new WebSocket("ws://"+$("#host").val()+"/socket");

client.onopen = doOpen;

client.onmessage = doMsg;

client.onclose = doClose;

dispStatus();

}

//处理open事件

function doOpen()

{

dispStatus();

}

//处理messsage事件

function doMsg(msg)

{

alert("收到消息:"+msg);

}

//处理关闭事件

function doClose()

{

dispStatus();

}

</script>

该代码实现的功能比较简单，就是连接用户输入的服务器地址，然后显示WebSocket的状态，如果收到服务器的消息，则弹出对话框显示消息。

需要注意的是，这个例子还需要支持WebSocket协议的服务器才能够运行，毕竟WebSocket只是客户端的套接字。目前，支持WebSocket协议的服务器有很多，如Tomcat7.0、Jetty7、Ruby、PHP、Node.js等。下面我们就以Node.js为例，实现支持WebSocket协议的服务器端。

5.3.3 案例：基于socket.io.js 框架的聊天室

Node.js本身不直接支持WebSocket协议，如果要自己实现WebSocket协议比较繁琐，需要参考Web sockets协议规范，根据协议规范内容对WebSocket协议数据进行处理。在此，我们直接使用socket.io这个类库，socket.io封装了Node.js服务器端，实现了WebSocket协议，同时也封装了套接字客户端，使用起来非常便利。

如图5-3所示，是一个用socket.io实现的简单的聊天程序，接下来看一下实现过程。

图5-3 简单的聊天室

服务器端使用socket.io步骤如下。

（1）从网站https://github.com/LearnBoost/socket.io下载socket.io。

（2）在自己的项目目录中建立node_modules目录，把下载的socket.io解压后放到node_modules目录下，目录结构如d:/myproj/node_modules/socket.io/*.*。

（3）使用require ("/socket.io")的方式导入socket.io。

导入后，就可以使用socket.io了。

以下代码显示了在服务器端如何监听客户端，收到客户端消息后输出消息内容：

//监听http服务器

var io = require("socket.io").listen(9000);

//注册连接事件

io.sockets.on("connection", function(socket){

//注册客户端接收消息事件

socket.on("message", function(msg){

//处理消息

doMsg(socket, msg);

})

});

//处理客户端消息

function doMsg(socket, msg)

{

//获取客户端ip地址

var addr = socket.handshake.address.address;

//后台打印消息

console.log("receive msg from "+addr+":"+msg);

//回传消息给客户端

socket.send("Server received messag!");

//广播消息给其他客户端

socket.broadcast.send(addr+" send msg:"+msg);

}

//处理http请求

function httpHandler(request, response)

{

}

代码中通过require ("socket.io").listen (9000)方法导入socket.io框架后，在9000端口开始监听。socket.io 服务器端和客户端采用异步事件的方式处理各种事件。一般来说，处理事件的形式类似于socket.on ("Event", handleEvent), event表示事件，handleEvent表示处理该事件的函数，在socket.io中已经内置了以下常见事件。

• connection：连接事件，客户端是connect事件。

• disconnect：断开连接事件。

• message：收到消息事件。

除了常见的事件以外，socket.io 还可以处理各种用户自定义事件，用户可以自定义事件名称，但要保证和客户端的事件名称相同。在服务器端使用了如下代码注册连接事件：

//注册连接事件

io.sockets.on("connection", function(socket){

//注册客户端接收消息事件

socket.on("message", function(msg){

//处理消息

doMsg(socket, msg);

})

});

在连接的handleEvent中有一个 socket 参数，该参数表示连接到服务器的客户端套接字，一旦连接成功后，就可以得到客户端的套接字，可以在该socket上注册事件，处理发生在该客户端上发生的事件。上述代码在客户端上注册了message事件，该socket上收到消息，则会触发doMsg方法。doMsg中的代码如下：

//获取客户端ip地址

var addr = socket.handshake.address.address;

//后台打印消息

console.log("receive msg from "+addr+":"+msg);

//回传消息给客户端

socket.send("Server received messag!");

//广播消息给其他客户端

socket.broadcast.send(addr+" send msg:"+msg);

doMsg中首先在后台打印消息，然后通过send方法把消息传递给客户端，最后通过socket的broadcast的方法把消息广播给其他客户端。broadcast方法用来给除了当前socket连接外的其他客户端发送消息。

下面来看看客户端如何使用socket.io来和服务器通信。

在客户端使用socket.io的步骤如下。

（1）将解压后的socket.io的目录下socket.io\node_modules\socket.io-client\dist\socket.io.js文件复制到项目目录。

（2）在html 文件中使用<script src="socket.io.js"></script>导入socket.io.js 文件。

以下代码显示了在客户端连接服务器，并发送消息。

<body>

服务器地址:<input id="host" value="localhost">

<input type="button" id="btnConn" value="连接服务器" /><br>

收到的消息:<br>

<textarea id="rmsg" rows="10" cols="60">

</textarea><br>

<input id="smsg" size="60"><input type="button" id="btnSend" value="发送消息" /><br>

</body>

<script src="../js/jquery.js"></script>

<script src="socket.io.js"></script>

<script>

//初始化

$(document).ready(function(){

//绑定建立连接

$("#btnConn").click(function(){

connect();

});

//绑定发送消息

$("#btnSend").click(function(){

sendMsg();

});

})

//定义全局客户端套接字

var socket = null,

isFirstConnect = true;

//连接服务器

function connect()

{

socket = io.connect("http://"+$("#host").val()+":9000", {'reconnect':false});

//注册连接事件

socket.on("connect", function(){

alert("连接服务器成功!");

//如果是第一次连接，注册事件

if(isFirstConnect)

{

isFirstConnect=false;

regEvent();

}

else

{

//重新连接

socket.socket.reconnect();

}

});

}

//注册收到消息事件

function regEvent()

{

socket.on("message", function(msg){

$("#rmsg").text($("#rmsg").text()+msg+"\r\n");

})

}

//发送消息事件

function sendMsg()

{

if(socket!=null)

{

socket.send($("#smsg").val());

}

}

</script>

代码的核心部分就是连接服务器和与服务器通信两部分，连接服务器通过以下代码实现：

socket = io.connect("http://"+$("#host").val()+":9000", {'reconnect':false});

connect 方法用于连接服务器，需要两个参数，第一个参数是需要连接的服务器，比如http://localhost:9000，第二个是额外的参数，reconnect 设置成 false 表示不会自动重新连接。然后，代码通过 socket.on ("connect")方法在客户端套接字注册了连接事件，一旦和服务器连接上，就会触发该事件，调用 connect 的事件处理函数。在客户端的连接事件中，给 socket 绑定了消息处理事件，实现代码如下：

function regEvent()

{

socket.on("message", function(msg){

$("#rmsg").text($("#rmsg").text()+msg+"\r\n");

})

}

在regEvent中注册了客户端的message事件，一旦客户端接收到服务器的消息后，就把消息内容加入到文本域中。

btnSend按钮则绑定了发送消息的方法，通过socket.send方法就可以发送文本消息。

到此，一个简单的使用socket.io的聊天程序已经完成。

5.4 小结

本章中我们学习了网络部分的基本知识，这些都是为能够实现网络游戏而建立的良好基础，在后面的网络游戏《你画我猜》的实战中，我们将会应用到本章所学的相关知识，到时将会给大家展示网络游戏开发的过程。

注　释

[1]. TCP/IP 最早是美国国防部高级研究计划局计算机网（Advanced Research Projects Agency Network，ARPANET）和其后继因特网使用的参考模型。ARPANET是由美国国防部（U.S．Department of Defense，DoD）赞助的研究网络。最初，它只连接了美国境内的四所大学。随后几年中，它通过租用的电话线连接了数百所大学和政府部门。最终ARPANET发展成为全球规模最大的互连网络——因特网。最初的ARPANET于1990年永久性地关闭。

[2]. TCP 和UDP 区别：TCP是一个"可靠的"、面向连结的传输机制，它提供一种可靠的字节流保证数据完整、无损并且按顺序到达。TCP尽量连续不断地测试网络的负载并且控制发送数据的速度以避免网络过载。另外，TCP试图将数据按照规定的顺序发送。这是它与UDP的不同之处，这在实现实时数据流应用的时候可能成为一个缺陷。UDP是一个无连结的数据报协议。它是一个"不可靠"协议，所谓的不可靠，是因为它不检查数据包是否已经到达目的地，并且不保证它们按顺序到达。如果一个应用程序需要这些特点，它必须自己提供或者使用TCP。UDP 的典型性应用是如流媒体（音频和视频等）这样按时到达比可靠性更重要的应用，或者如 DNS 查找这样的简单查询/响应应用，如果建立可靠的连接所做的额外工作将是不成比例的大。

[3]. V8 引擎是由Google 公司开发的，应用在Chrome 中，目前最快的JavaScript引擎，它的JIT(即时编译引擎)的速度已经媲美本地代码执行的速度。

[4]. Git 是一个分布式的版本控制系统，最初由Linus Torvalds编写，用作Linux 内核代码的管理。在推出后，Git在其他项目中也取得了很大成功，尤其是在Ruby社区中。目前，包括Rubinius和Merb在内的很多知名项目都使用了Git。Git同样可以被诸如Capistrano 和Vlad the Deployer 这样的部署工具所使用。

第6章 游戏常用算法

从20世纪50年代第一个有历史意义的电子游戏诞生到现在经过了60年的时间，游戏发展的脚步越来越快，快的我们都跟不上它的步伐，特别是游戏的画面从简单的阴极射线管娱乐装置到现在的越来越真实的如 CG 般的画面，一切都像是梦幻一般。经过几十年的发展，游戏的画面的确有了翻天覆地的变化，但有一些质的东西不会因为画面的变化而改变，它们就像是游戏的灵魂一样，这就是游戏中的算法。

本章中涉及的算法主要以介绍游戏中常用的 AI 算法，即人工智能算法，当然这部分是游戏中经常使用到的一些算法，比如：

• 追踪算法；

• 碰撞检测；

• 路径搜索；

• 有限状态机；

• 粒子系统。

6.1 追踪算法

追踪算法在动作游戏中非常常见，从很早的游戏《吃豆人》到大型的街机机战类游戏，到处可见追踪效果的身影。一个好的追踪算法将会大大提高游戏的可玩性和玩家的兴趣。

先来看一个简单的跟踪算法，如图6-1所示，假设在canvas坐标系中存在物体A和B，物体A将把B作为追踪目标，物体在二维空间中的运动可以分解为坐标系中X、Y轴的运动，其在X和Y方向的速度决定了物体运行的方向和速率。别忘了，速度是有方向和大小的，于是物体A的速度在X、Y轴方向分解成vx、vy，B物体也是一样，这样，如果物体A要追踪到B，只需要比较两个物体分别在 X、Y 方向的速度即可。设物体 A 坐标为(x1, y1)，A 的速度分解为(vx, vy)，物体B 坐标为(x2, y2)，B 的速度分解为(vx1, vy1)，假设A 要追到B，对于水平X 方向分量来说，如果x2>x1，表示B在A的右边，这时候必须设置vx为某一个正值，反之，则需要将vx设置成一个负值，同样的道理，对于垂直方向Y来说，需要进行同样的处理即可。

基于以上这个简单算法的原理，可以来尝试一个简单的例子。如图6-2所示，玩家需要操作飞机以躲避子弹的追踪，游戏中将会随机产生30发子弹用来追踪飞机。

图6-1 物体A、B速度分解

图6-2 躲避子弹的飞机

有了前面制作打砖块游戏的经验，相信大家对于Xengine的使用应该很了解了吧。本演示案例中只需要一个场景就可以了，同时需要玩家类和子弹类，当然，这两个类最好都继承至Sprite对象，先来看看玩家类的实现：

(function(){

//从引擎的RenderObj继承

Player = Sprite.extend({

//重写update

update:function()

{

if(Key.pressed(Key.A))

{

this.move(-2, 0);

}

if(Key.pressed(Key.D))

{

this.move(2, 0);

}

if(Key.pressed(Key.W))

{

this.move(0,-2);

}

if(Key.pressed(Key.S))

{

this.move(0, 2);

}

}

});

Player.ClassName = "Player";

//注册Ball类

ClassFactory.regClass(Player.ClassName, Player);

}())

玩家类的实现比较简单，根据游戏的规则，只需要重写Sprite的update方法，同时玩家还可以通过W、S、A、D按键控制飞机的移动。

子弹类则稍微复杂一些，因为在子弹类中需要追踪飞机，正好可以应用到前面刚介绍的追踪算法。

(function(){

//从引擎的RenderObj继承

Bullet = Sprite.extend({

//重写update

update:function()

{

var p=TGame.player;

//最简单的追踪算法

this.dx = (this.x>p.x)?-1:1;

this.dy = (this.y>p.y)?-1:1;

this._super();

}

});

Bullet.ClassName = "Bullet";

//注册Ball类

ClassFactory.regClass(Bullet.ClassName, Bullet);

}())

在Bullet类中，我们同样重写了Sprite的update方法，根据例子的需要，修改Bullet的运动速度来追踪玩家。这里应用到了前面所介绍的最简单的追踪算法，首先获取玩家的坐标信息，然后根据子弹和玩家的坐标来修改子弹的运行速度。

最重要的算法在Bullet中得以体现，下面看看整个游戏的Game类的实现：

(function(win){

var _Game = Game.extend({

player:null,

init:function()

{

this._super();

var scm = this.sceneManager;

//创建场景

var x = (document.body.clientWidth-500)*0.5;

scm.createScene([{"x":x, "w":500, "h":400, "name":"main"}]);

this.loadRes();

},

loadRes:function()

{

var self = this;

ResManager.loadRes("data/res.json", function(){

self.initSprite();

self.run(60);

});

},

initSprite:function()

{

var sc = this.sceneManager.getScene("main");

//创建飞机

var p = sc.createRObj(Player.ClassName);

p.setAnims(ResManager.getAnimationsByName("sprite", "player"));

p.w = p.h = 60;

p.moveTo(100, 100);

this.player = p;

//创建子弹

for(var i=0;i<30;i++)

{

var b=sc.createRObj(Bullet.ClassName);

b.setAnims(ResManager.getAnimationsByName("sprite", "b1"));

b.w = b.h =10;

b.dx = b.dy = 1;

b.moveTo(MathUtil.randInt(500), MathUtil.randInt(400));

}

}

})

//定义全局TGame

win.TGame = new _Game();

}(window))

这里，我们定义了一个TGame作为Game的子类，其基本的使用步骤和前面类似，先创建场景、加载资源、创建精灵。这里，创建了一个Player对象和30发子弹对象，然后让它跑起来，这样，大家就可以操作飞机来躲避子弹了。这让我想起了以前一个比较流行的小游戏叫：是男人就坚持20秒钟，相信大家现在应该可以做一个类似的小游戏了。

采用这种算法的子弹的行动比较突兀，子弹的变向比较突然，效果开起来不是非常理想，于是，就产生了下面的视线追踪算法。

视线追踪算法，采用这种算法，追踪者将会始终保持着和目标对象的直线进行移动，如图6-3所示，看起来就好像追踪捕食的猎豹一样，死死地盯着目标不放。

如果要达到这种效果，实际上就表示在任意时刻，A 的速度方向必须保持在 AB 之间连接的直线上面，那么这个时候如何获取A的速度在x轴和y轴方向上的分量呢？

这里我们可以采用向量来解决问题，向量是一种只有方向和大小而没有位置的概念，由向量的知识可知，假设任意时刻物体 A 向量表示为 v1(x1, y1)，物体 B 向量表示为 v2(x2, y2)，则由A 指向B 位置的向量v3=(x2−x1, y2−y1)。这3 个向量的关系可以由图6-4 表示出来，设向量v3的长度为VLen，则向量v3标准化后可以用v4=((x2−x1)/VLen，(y2−y1)/VLen)表示。最后得到的v4在x轴方向上的分量就可以作为物体A在该时刻X轴方向上的分量，v4在Y轴方向上的分量就可以作为物体A在该时刻Y轴方向上的分量。

图6-3 追踪B 的A物体

图6-4 向量关系

有了以上算法作为理论基础，就可以把Bullet类中的update算法做如下修改：

update:function()

{

var p=TGame.player, dx, dy;

//视线追踪算法

var vx = p.x-this.x,

vy = p.y-this.y,

rlen = 1/Math.sqrt(vx*vx+vy*vy),

dx = vx*rlen,

dy = vy*rlen;

this.dx = dx;

this.dy = dy;

this._super();

}

经过这样改进之后，子弹运行的方式看起来自然了许多。

接下来看看游戏中常用的碰撞检测算法。

6.2 碰撞检测算法

碰撞检测算法是游戏中经常用到的算法之一，几乎各类游戏都会涉及碰撞检测，常见的比如动作游戏、射击类游戏、RPG（SLG），甚至连 PUZ 等游戏都会涉及游戏物体之间的碰撞。检测游戏物体之间的碰撞算法有很多种，有的算法简单、效率高，但检测的精度较低，有的算法复杂、效率低，但检测的精度很高，这就需要根据具体的游戏需要进行选择。这里，我们主要介绍常用的几种算法，基本上可以划分为两类，一类是采用几何算法，另一类采用像素算法，首先来看看几何算法。

常用的检测碰撞的几何算法中，主要涉及以下几类：

• 地图格子检测；

• 圆形检测；

• 矩形检测；

• 凸多边形检测。

让我们依次来讨论这几种算法。

6.2.1 地图格子算法

这种算法经常用于RPG（早期的《最终幻想》、《DQ》、《仙剑奇侠传》）、SLG（《炎龙骑士团》、《超级机器人大战》）、PUZ（《俄罗斯方块》、《宝石谜阵》）类型的游戏。这类游戏中，通常情况下整个地图都是由一些地图块元素组成，在制作的时候首先给制作出地图所需要的最基本的元素进行编号，然后把这些编号的地图块组合起来就可以根据需要形成任意大小的地图。图6-5所示的基本地图元素以及图6-6所示的由这些基本元素组成的游戏地图。

这两幅地图截选自著名的RPG游戏制作工具RPG Maker——一种制作日式RPG 的游戏制作工具，可以看出，RPG早期的风格基本就是这样。早期的RPG类型或者SLG类型的游戏可以明显地看出游戏中的地图是由一些小的地图块格子而成，采用这种方式组成地图的好处是节约内存的使用，并且不需要太多的地图元素就可以任意组合成足够大的地图，简单灵活，缺陷就是最后制作出的地图不太美观。现在的RPG类型的游戏几乎看不到这样的格子划分，虽然现在的游戏中采用整张大图表示地图，但实际上为了便于游戏中的碰撞检测，比如人物碰到NPC或者是碰到不可跨越的障碍等情况，在游戏中实际上还是保存了一张看不见的逻辑层。这个层的大小和地图等大，并且也进行了格子划分，主要目的就是为了碰撞检测，事实上大家也看到了在图 6-5 中，有的地图元素中用叉做了标记，有的地图元素中用了圆做标记，这样就表示，地图中使用叉做标记的元素是不可通过的地方，人物或者NPC碰到这些地图元素则不会跨越。通常我们在游戏中对这个碰撞逻辑层使用一个二维数组描述信息，使用 1 表示不可以通过，0表示可以通过，假设人物和NPC在游戏中行走，这种情况下我们就很容易检测地图中人物是否碰到了NPC或者障碍物。

图6-5 基本地图元素

图6-6 游戏中的地图

如图6-7所演示的，假设物体A是主角，占一个格子大小，物体B表示一个房子，占用9个格子大小，这9个格子表示的逻辑层的数据都是1，表示不可跨越，这种情况下检测A和B物体是否碰撞比较简单，只需比较A和B物体在二维数组中的横、纵坐标值就可以了，假设A正往左行走， A的坐标为（5，3），那么A的下一个位置（4，3）所在位置坐标的逻辑值正好是 1，则就表示无法通过了。

图6-7 格子碰撞

采用这种方式判断逻辑极其简单，效率也比较高，但不太精确，如果A物体的大小比格子小很多，则物体行动的时候可能看起来离B物体有些距离就无法行走了，所以做这种类型游戏最好保证格子足够小或者保证人物大小和格子相差不大，在后面的寻路算法中我们使用了这种方式处理。

6.2.2 包围盒检测算法

接下来要介绍的算法，圆形检测算法、矩形检测算法和凸多边形检测算法本质上都是属于包围盒算法。

检测物体碰撞实际上是需要检测物体是否相交，而实际应用中物体的形状大小各异，如果直接对物体的边缘进行碰撞检测，实际计算过程的代价非常高昂。如果物体的数量太多，比如像网络游戏中，通常少则几千用户，多则上万、几十万用户同时在线，而这些碰撞都要通过服务器检测，这样计算的消耗，即使是大型服务器也会崩溃，所以通常不需要十分精确的碰撞检测情况下，使用包围盒算法，即把物体放在一个多边形中，这个多边形就是包围盒。

如图6-8所示，使用矩形包围盒围住飞机和子弹，如果这个包围盒和其他的物体包围盒产生了碰撞，则就认为两个物体发生碰撞。如图 6-8 所示，这个多边形最好不要比原物体复杂，否则会得不偿失。

基于这样一个概念，通常情况下我们就使用了最简单的几种多边形，比如圆形、矩形等。

先来看看如何使用圆形包围盒算法，圆形包围盒算法就是使用圆形作为包围盒将物体包围起来，检测的时候只需要检测两个圆形是否碰撞即可。如图 6-9 所示，检测圆形的碰撞比较容易，假设圆 A 的坐标(x1, y1)，半径是 r1，圆 B 的坐标(x2, y2)，半径是 r2，则如果满足不等式(y2−y1)2+(x2−x1)2≤(r1+r2)2则表示两个圆发生了碰撞，其实就是圆心之间的距离小于两个圆的半径之和即可，由于计算距离需要用到开方运算，效率较低，所以直接比较距离的平方。

图6-8 矩形包围盒

图6-9 圆形包围盒

采用这种算法优点明显，算法比较简单，效率还算比较高，在某些情况下检测也比较精确，比如射击类游戏中，如果飞机和子弹都接近圆形则效率还不错，但如果相差巨大，则检测也不精确，如图6-10所示，射击类游戏中使用了导弹，而导弹是长方形，则检测偏差较大。

图6-10 导弹使用圆形包围盒

在这种情况下使用圆形包围盒来运算则检测的误差有点大，有可能导弹还没碰到物体就认为是发生了碰撞，所以这种情况下使用矩形包围盒比较精确。

矩形包围盒，顾名思义，就是使用一个矩形来包围住图像，矩形的大小以刚好包围住图像为最佳，这种包围盒最适用的场景是刚好物体的形状接近于矩形。

在具体的应用中，描述矩形包围盒的的常用方式有以下两种，分别如图6-11和图6-12所示。

图6-11 采用最小最大顶点法描述AABB包围盒

图6-12 采用点和半径描述AABB包围盒

在图6-11中使用了最小最大顶点法来描述包围盒信息，由于是在屏幕坐标系中，y轴是向下延伸的，所以只需要保留矩形中坐标的最小值和最大值即可，即矩形的左上角和右下角的顶点，其他的点都在这两个点范围内。

在这种情况下要判断两个矩形是否碰撞只需要比较两个矩形顶点的坐标即可，假设矩形 A用(x1, y1)表示左上角，(x2, y2)表示右下角，矩形B用(x3, y3)表示左上角，(x4, y4)表示右下角，则满足下列条件则表示没有碰撞，反之则碰撞。

• 没碰撞：x1>x4 或者x2<x3。

• 没碰撞：y1>y4 或者y2<y3。

在图6-12中使用了中心点和对应两个轴的半径来描述包围盒信息，假设有两个矩形A和B，矩形A 的中心坐标为A(x1, y1)，宽度和高度分别为rx1、ry1，矩形B 的中心坐标为B(x2, y2)，宽度和高度分别是rx2、ry2，则采用这种包围盒检测方式如下。

如果满足两个矩形在x方向的距离小于两个矩形宽度和的一半，并且在y方向上的距离小于两个矩形高度和的一半则表示两个矩形有重叠，即表示发生碰撞，换成公式如下：

当然，也可以把这种形式换算成第一种形式演算，这两种方式很显然第一种的效率比较高效一点，毕竟第二种算法需要使用Math.abs获取绝对值，第一种只是单纯使用了坐标比较。

以上所描述的矩形包围盒也称为 AABB（轴对齐）包围盒，轴对齐包围盒中的矩形的四条边分别和坐标轴平行，实际上也就是表示该矩形没有进行过旋转操作，使用轴对齐包围盒检测算法比较简单高效，精度上也能满足大多数条件，因此实际应用中也比较多。

一般来说，使用以上两种包围盒可以做到大多数的碰撞检测，但如果涉及比较复杂的图像，以上两种算法的精度有限，所以为了进一步提高精度，可以采用凸多边形分离轴检测算法。

先来简单了解一下分离轴定理（separating axis theorem，SAT），如果能找到一条轴，使得两个物体在该轴上的投影互不重叠，那么这两个物体就是不相交的。图6-13显示了两个凸多边形在一条分离轴上的投影，显然这两段投影是没有相交的。

图6-13 两个多边形在分离轴上的投影

这种问题的关键是找到一条可以满足两个物体在其上面的投影不重叠的轴，这种算法只适用于凸多边形。所谓凸多边形，就是把一个多边形任意一边向两方无限延长成为一条直线，则多边形的其他各边均在此直线的同旁，例如三角形、四边形、六边形、圆形等。对于非凸多边形，可以将其分解为多个凸多边形。

看起来，要找到这条轴是比较麻烦的，因为一个平面上的线有无数条，但经过严格的理论证明，在2维平面空间中，只需要遍历两个多边形的每条边的法向量作为分离轴进行测试即可，如图6-14所示，该图形四条边的四个法向量即可作为测试轴。

所以在做检测的时候只需要按照以下步骤进行即可。

（1）产生所有的分离轴，选取一条测试。

（2）计算图形在该分离轴上的投影。

（3）检测投影是否相交，如果相交则选取下一条，重复步骤2和步骤3，如果不相交则返回不相交。

（4）所有分离轴检测完毕，返回相交。

这里，我们先来简单回顾一下向量的概念，向量在数学上也叫矢量，指一个同时具有大小和方向的几何对象，因常常以箭头符号标示以区别于其他量而得名。直观上，矢量通常被标示为一个带箭头的线段，如图6-15所示。线段的长度可以表示矢量的大小，而矢量的方向也就是箭头所指的方向。

图6-14 多边形的法向量

图6-15 向量AB和CD

图6-15中显示的两个向量分别是AB和CD，向量AB表示从点A指向点B的一条有向线段，向量CD 也是一样。假设点A 的坐标为(x1, y1)，点B 的坐标为(x2, y2)，则向量AB 可以使用坐标(x2−x1, y2−y1)表示，AB 的法向量即垂直于 AB 的向量可以使用(y1−y2, x2−x1)或者(y2−y1, x2−x1)表示，目前知道这些对于解决我们的问题就足够了。实际上 AB 和 CD 这两个向量是相等的，根据向量的概念得知，两个向量只要大小和方向相同，则表示两个向量相同，向量是没有位置概念的。

根据以上的这些概念，我们现在可以求出任意一条边的法向量了，剩下的一个问题是如何求出一个向量在另一个向量上的投影，如图 6-16 所示，向量 v(vx, vy)在 u(ux, uy)上面的投影可以使用点积来得出。

两个向量的点积表示为两个向量在x、y轴上分量的乘积之和，最终得到的是一个标量，比如v和u的点积为：u·v=vx×ux+vy×uy。

而点积的几何意义是v在u上的投影并返回v沿u方向，|u|个单位的有向距离，|u|表示的是向量u的模，即u 的长度，所以对于v在u 上的投影的距离表示为：d = v·u/|u|。

如图6-17所示，对于图中两个多边形来说，检测它们是否相交，我们所要做的就是计算两个多边形的每条边在分离轴上的投影的距离 d，注意 d 是一个向量。找出每条边形成的向量在轴上投影的最大值和最小值，这样在分离轴上每个多边形就分别以这两个值形成线段，最后比较这两个线段是否重叠就可以判断这两个多边形是否相交了。

图6-16 v 在u上的投影

图6-17 多边形ABCD

以下代码可以得出任意一个多边形在某一个轴上投影的最大最小值：

//计算polyArr在axis上的投影，polyArr是一系列点坐标的集合，数组表示

calcProj:function(axis, polyArr)

{

var v = {"x":polyArr[0], "y":polyArr[1]};

var d, min, max;

min=max = MathUtil.dotV2(axis, v);

for(var i=2;i<polyArr.length-1;i+=2)

{

v.x=polyArr[i];

v.y=polyArr[i+1];

d = MathUtil.dotV2(axis, v);

min = (d<min)?d:min;

max = (d>max)?d:max;

}

return [min, max];

}

至于比较两个线段是否相交，可以用以下函数：

//计算同一个轴上线段的距离s1(min1, max1)，s2(min2, max2)，如果距离小于0 则表示两线段有相交

segDist:function(min1, max1, min2, max2)

{

if(min1<min2)

{

return min2-max1;

}

else

{

return min1-max2;

}

}

基本上，有了以上的这些知识，我们就可以对几种包围盒进行实现了。

由于这几种包围盒比较常见，下面来看看每种具体的实现。

先来实现一个简单的包围盒的基类，代码如下：

//包围盒抽象类

var _bBox = win.BBox = Class.extend({

init:function(x, y)

{

//中心坐标

this.x = x;

this.y = y;

//颜色

this.c = "red";

},

isCollide:function(bbox)

{

if(this.constructor.ClassName!=bbox.constructor.ClassName)

{

throw Error("Box Type mismatch!");

}

else

{

return this.collided(bbox);

}

},

collided:function(bbox)

{

throw Error("This method must be override by child class!");

},

getType:function()

{

return this.constructor.ClassName;

},

//显示包围盒

show:function(ctx)

{

throw Error("This method must be override by child class!");

}

});

包围盒的基类中涉及几个主要的方法，isCollide()方法是判断是否和另一个包围盒发生碰撞，也就是相交。方法的实现中做了一个简单的验证，判断包围盒类型是否相同，如果盒子类型不相同则无法检测，否则调用collided()方法检测是否和另一个包围盒相交，这里没有具体的实现，具体的实现代码由具体的子类实现。为了演示方便，还提供了一个show()方法用于显示包围盒。

接下来是圆形包围盒的实现：

//圆形包围盒

var _RBBox = win.RBBox = BBox.extend({

//x和y为圆心坐标，r是半径

init:function(x, y, r)

{

this.x=x;

this.y = y;

this.r = r;

this._super();

},

collided:function(tBox)

{

var dx = this.x-tBox.x,

dy = this.y-tBox.y,

dr = this.r+tBox.r;

return dx*dx+dy*dy<dr*dr;

},

show:function(ctx)

{

ctx.beginPath();

ctx.strokeStyle =this.c;

ctx.lineWidth = 2;

ctx.arc(this.x, this.y, this.r, 0, Math.PI*2);

ctx.stroke();

}

});

根据前面的理论，相信应该比较容易理解。

AABB矩形包围盒实现如下：

//矩形包围盒

var _ABBox = win.ABBox = BBox.extend({

init:function(x, y, hw, hh)

{

this.hh=hh;

this.hw = hw;

this._super(x, y);

},

collided:function(tBox)

{

var nx=this.x-this.hw,

ny = this.y-this.hh,

mx = this.x+this.hw,

my = this.y+this.hw,

nx1 = tBox.x-tBox.hw,

ny1 = tBox.y-tBox.hh,

mx1 = tBox.x+tBox.hw,

my1 = tBox.y+tBox.hw;

if(nx>mx1||mx<nx1)return false;

if(ny>my1||my<ny1)return false;

return true;

},

show:function(ctx)

{

ctx.beginPath();

ctx.lineWidth = 2;

ctx.strokeStyle = this.c;

ctx.strokeRect(this.x-this.hw, this.y-this.hh, this.hw*2, this.hh*2);

}

});

在此处，AABB矩形的定义使用点和半径法定义。

接下来是凸多边形包围盒：

//凸多边形包围盒

var _pBBox = win.PBBox = BBox.extend({

//x 和 y 是多边形中心坐标，pArr 是一个顶点数组，点的坐标采用相对中心点坐标，按顺时针存放各顶点

init:function(x, y, pArr)

{

this.x=x;

this.y = y;

this.pArr = pArr;

this._super();

},

//转换所有顶点坐标到绝对坐标系中

mapToWorld:function()

{

var p=[];

for(var i=0, len = this.pArr.length;i<len-1;i+=2)

{

p.push(this.pArr[i]+this.x, this.pArr[i+1]+this.y);

}

return p;

},

collided:function(tBox)

{

var p1=this.mapToWorld(),

p2 = tBox.mapToWorld();

return MathUtil.isCollide(p1, p2);

},

show:function(ctx)

{

ctx.beginPath();

ctx.strokeStyle = this.c;

ctx.lineWidth = 2;

//移动到第一个点

ctx.moveTo(this.pArr[0]+this.x, this.pArr[1]+this.y);

for(var i=2, len=this.pArr.length;i<len-1;i+=2)

{

ctx.lineTo(this.pArr[i]+this.x, this.pArr[i+1]+this.y);

}

ctx.closePath();

ctx.stroke();

}

})

凸多边形包围盒中使用数组描述凸多边形，按照顺时针的顺序存放每个顶点的x和y坐标，比如[x1, y1, x2, y2, …]，其中的collided 的检测方法中使用到了MathUtil.isCollide()方法，该方法用于检测两个凸多边形是否发生相交，该方法的具体实现如下：

//判断两个多边形是否相交碰撞，p1和p2用于保存多边形点的数组

isCollide:function(p1, p2)

{

//定义法向量

var e = {"x":0, "y":0};

var p = p1, idx=0, len1=p1.length, len2=p2.length;

for(var i=0, len = len1+len2;i<len-1;i+=2)

{

idx=i;

//计算两个多边形每条边

if(i>len1)

{

p=p2;

idx=(i-len1);

}

if(i==p.length-2)

{

px=p[0]-p[idx];

py=p[1]-p[idx+1];

}

else

{

px=p[idx+2]-p[idx],

py = p[idx+3]-p[idx+1];

}

//得到边的法向量

e.x = -py;

e.y = px;

//计算两个多边形在法向量上的投影

var pp1 = MathUtil.calcProj(e, p1);

var pp2 = MathUtil.calcProj(e, p2);

//计算两个线段在法向量上距离，如果大于0则可以退出，表示无相交

if(MathUtil.segDist(pp1[0], pp1[1], pp2[0], pp2[1])>0)

{

return false;

}

}

return true;

}

根据前面的理论知识，相信理解这个算法应该不是非常困难。

最后，我们来看一个利用包围盒算法运行的小例子，如图6-18所示截图，左边下拉列表项可以对精灵使用不同的包围盒检测，如果两个精灵发生碰撞则包围盒显示黄色，否则显示红色。

图6-18 包围盒碰撞

为了能够使用包围盒，这里我们使用了一个测试精灵类，该类的代码如下：

//带包围盒的精灵类

(function(){

//从引擎的RenderObj继承

CObj = RenderObj.extend({

init:function()

{

this.bBox=null;

this._super();

},

//设置包围盒

setBBox:function(bBox)

{

this.bBox=bBox;

},

//重写update

update:function()

{

var w=this.owner.w,

h = this.owner.h;

//到达边界改变速度方向

if(this.x<this.w||this.x>w)

{

this.dx=-this.dx;

};

if(this.y<this.h||this.y>h)

{

this.dy=-this.dy;

}

this.bBox.c = "red";

//调用父类方法

this._super();

//检测是否碰撞其他CObj

this.checkCollide();

},

checkCollide:function()

{

var cObjs=TGame.cObjs;

for(var i=0, len = cObjs.length;i<len;i++)

{

if(this.name!=cObjs[i].name&&this.bBox.c!='yellow'&&this.bBox.isCollide

(cObjs[i].bBox))

{

this.bBox.c=cObjs[i].bBox.c="yellow";

}

}

},

render:function(ctx)

{

var bType=this.bBox.getType();

this.bBox.x = this.x;

this.bBox.y = this.y;

this.bBox.show(ctx);

}

});

CObj.ClassName = "CObj";

//注册CObj类

ClassFactory.regClass(CObj.ClassName, CObj);

}())

和以前的渲染类一样，本类从RenderObj继承，只是简单的复写了update和render方法，但在该类中多了一个bBox属性，用于存放对该对象实施碰撞检测的包围盒。update方法中包含除了碰撞墙壁的逻辑外，还包含了检测是否和其他精灵碰撞的逻辑，如果和其他精灵发生碰撞则会把包围盒的颜色设置为黄色，在render方法中，将显示包围盒。

然后，我们看看游戏的主体部分，在game.js文件中：

(function(win){

var _Game = Game.extend({

//记录碰撞物体

cObjs:[],

init:function()

{

this._super();

var scm = this.sceneManager;

//创建场景

var x = (document.body.clientWidth-500)*0.5;

scm.createScene([{"x":x, "w":500, "h":400, "name":"main"}]);

this.initSprite();

this.setBBox(0);

this.run(60);

},

//给精灵设置包围盒，0 表示圆形，1 表示矩形， 2 表示凸多边形

setBBox:function(type)

{

var bbox=null;

for(var i=0, len = this.cObjs.length;i<len;i++)

{

var b=this.cObjs[i];

switch(type)

{

case 0:

bbox = new RBBox(b.x, b.y, b.w*0.5);

break;

case 1:

bbox = new ABBox(b.x, b.y, b.w*0.5, b.h*0.5);

break;

case 2:

var pArr = [-15, 15, 0, 19, 15, 15, 10, -10, -25, -15];

bbox = new PBBox(b.x, b.y, pArr);

break;

}

//设置包围盒

b.setBBox(bbox);

}

},

initSprite:function()

{

var sc=this.sceneManager.getScene("main");

//创建碰撞物体

for(var i=0;i<20;i++)

{

var b=sc.createRObj(CObj.ClassName);

b.w = b.h = MathUtil.randInt(25, 45);

b.moveTo(MathUtil.randInt(50, 500), MathUtil.randInt(50, 400));

b.dx = MathUtil.randInt(2, 5);

b.dy = MathUtil.randInt(2, 5);

this.cObjs.push(b);

}

}

})

//定义全局TGame

win.TGame = new _Game();

}(window))

与之前使用游戏引擎的方式一样，继承Game类，然后初始化精灵类，同时该Game类中提供了setBBox()方法用于给游戏中所有的精灵设置不同的包围盒。

好了，关于采用几何图形的方式解决碰撞先了解到这里，为了兼顾效率和精度，包围盒算法是最常用的一种算法。下面将要描述的采用像素碰撞的算法精度最高，但效率最低，对于使用js这种脚本语言来说，最好不要采用像素碰撞的算法。

6.2.3 像素检测算法

使用像素碰撞检测法算是最精确的算法了，当然，带来的代价也是比较明显的，那就是效率上的低下。除非是在极为特殊的情况下，要求使用非常精确的碰撞，否则，一般情况下在游戏中是不建议使用这种算法，特别是在运行效率不太高的HTML5游戏中。

一般来说在使用像素碰撞检测之前会使用AABB矩形包围盒先检测两个精灵是否有碰撞，如果AABB包围盒检测没有碰撞，那一定是没有碰撞到，反之，则不一定，需要进一步进行像素检测。如图6-19所示，很明显，虽然两个精灵的包围盒发生了碰撞，但两个精灵本身没有发生碰撞，所以在这种精灵的形状极为不规则的情况下，除非使用多边形包围盒，并且需要多边形和精灵的形状极为接近，才能够获取好的效果，而且，别忘了，多边形只适合凸多边形的情况。这样，我们就只能采用像素检测算法达到精确检测的目的。

接下来，先来看看像素碰撞的原理，首先，我们知道所有的精灵都是由像素点组成，而在canvas的像素数据中每个点都是由RGBA四个数据组成。如果某个点的A（alpha值，透明度）为0则表示该点是透明的，比如在图6-19中两个精灵的空白部分的点的A值为0。如果两个精灵发生碰撞，则表示两个精灵有像素点发生了重叠，即两个精灵的像素点坐标相同，如图6-20所示。

图6-19 包围盒相交的精灵，但精灵没相交

图6-20 发生相交的两个精灵

根据这个原理，基本上我们可以采取以下步骤来进行检测。

（1）选择需要检测的两个精灵。

（2）先检测两个精灵是否发生包围盒碰撞，如果没有则退出，否则获取两个矩形的相交区域，并继续。

（3）把一个精灵绘制到和游戏屏幕等大的空白的后台缓冲区中，获取缓冲区中在相交区域的像素数据。

（4）清除后台缓冲区。

（5）对另一个精灵进行步骤3的操作。

（6）得到两个精灵在同一个相交矩形的像素数据后，循环比较每一个像素点，如果两个精灵在同一位置的透明度不都是0，则表示两个精灵有相交，退出循环，返回真。

需要注意的一点是，在第3步获取相交区域的像素数据中，需要在后台另外的一个和游戏屏幕等大的空白区域中绘制，而不能直接获取游戏画面中的相交区域，因为两个精灵在相交区域中的像素已经发生了重叠。

由以上的算法可以看出，在进行像素检测的时候，需要另外一个缓冲区，把需要检测的精灵绘制一次，需要清空缓冲区，最后还要使用一个for循环检测像素。如果相交区域为100×100个像素点，则最坏的情况需要循环10 000 次，由此看来，这真不是一个省时的工作。

以下的代码片段实现了精灵相互间实行像素碰撞的检测：

//检测是否和其他精灵碰撞

isCollide:function(pObj)

{

var backBuf = TGame.backBuf;

backBuf.clearRect(0, 0, this.owner.w, this.owner.h);

var x1 = this.x-this.w*0.5,

y1 = this.y-this.h*0.5,

x2 = this.x+this.w*0.5,

y2 = this.y+this.h*0.5,

x3 = pObj.x-pObj.w*0.5,

y3 = pObj.y-pObj.h*0.5,

x4 = pObj.x+pObj.w*0.5,

y4 = pObj.y+pObj.h*0.5;

//获取相交区域

var rc = MathUtil.getInRect(x1, y1, x2, y2, x3, y3, x4, y4);

//如果没有相交则退出

if(rc[0]>=rc[2]||rc[1]>=rc[3])

{

return false;

}

else

{

var gData1 = null, gData2 = null;

backBuf.save();

//绘制到后台缓冲中

this.render(backBuf);

backBuf.restore();

//获取精灵在相交矩形像素数据

gData1 = backBuf.getImageData(rc[0], rc[1], rc[2], rc[3]).data;

//绘制目标精灵

backBuf.clearRect(0, 0, this.owner.w, this.owner.h);

backBuf.save();

pObj.render(backBuf);

backBuf.restore();

//获取目标精灵在相交矩形像素数据

gData2 = backBuf.getImageData(rc[0], rc[1], rc[2], rc[3]).data;

for(var i=3;i<gData1.length;i+=4)

{

if(gData1[i]>0&&gData2[i]>0)return true;

}

}

}

这部分代码就是根据前面的步骤加以实现，该代码节选自随书代码例子，在该例子中，读者可以任意拖动游戏中的两个精灵，如果它们发生了碰撞，则会弹出警告窗口。

到此，关于碰撞检测的算法部分就到这里，游戏中大部分使用的碰撞检测基本上都涉及了，以上所描述的算法在 3D 游戏也可以适用，但由于多了一条轴，计算起来更为复杂一些。在实际应用中有很多游戏为了效率的考虑，基本只会使用到AABB和圆形包围盒进行处理。此外，由于以上算法只是单纯涉及精灵之前碰撞的检测，还没有涉及精灵如何组织，正常情况下，游戏循环中，每一个精灵都需要和其他精灵进行检测，这样的时间复杂度基本是O(n^2)，效率上还是比较低。关于如何有效组织精灵，提高碰撞检测的效率，可参考有关空间划分的算法，比如采用网格算法、四叉树等。

接下来的算法也是游戏中常使用的路径搜索算法，也称寻路算法，常用在游戏中人物的自动行走中，比如魔兽世界中，在地图的任意一点点击，人物就会自动移动到指定的位置。

6.3 路径搜索算法

路径搜索算法在游戏中非常常见，特别是在 RPG、SLG 中经常用到。在这些游戏中，通过鼠标指定行走目的地，人物或者NPC就会自动行走到目标地点，这就是通过路径搜索或者称为寻路算法来实现的。通俗地说，就是在一张地图中，如何让主角自动行走到指定的地点，如图6-21所示，假设主角在A处，然后玩家在地图中点击B处，要求主角能够从A点自动找寻一条到 B 点的路径，然后自动移动到 B处，要求就这么简单。

图6-21 地图自动寻路

在前面的碰撞检测算法中，我们提到，现在的游戏中的地图一般采用格子的方式，虽然表面地图上无法看到实际的格子，但在地图的结构中专门有一个逻辑层，这个层和地图大小等大，划分出很多小的格子，然后在可以通过的地方使用0表示，有障碍的且不能通过的地方使用 1 或其他数字表示。如图6-22所示，左边的游戏中的地图，程序中会以右边的一个二维数组保存一个逻辑层，专门用来设定障碍。有了这个逻辑层之后，实际上自动寻路就转化成了，如何在一个二维的数组中找到一条从逻辑值为 0 的地点移动到目标地点的路径。

图6-22 地图障碍逻辑层采用二维数组保存

在介绍如何使用自动寻路算法前，我们先来看另外一个游戏常用的算法，即随机产生地图（迷宫）算法，用于结合寻路算法。

6.3.1 随机迷宫算法

根据前面的地图的理论，本质上，地图的障碍逻辑层是由一个二维数组保存，障碍标记在二维数组中的数据值以0或1表示，我们需要做的就是随机产生这个二维的数组。当然，最简单的办法就是循环这个二维数组然后在每一个位置随机地产生 0 或者 1，但这种算法产生的图形比较难看，并且不一定保证图中的任意两点可以相连通。

在随机生成的迷宫中要求任意两点，都可以找到一条路径可以相通，所以在图论中可以认为迷宫就是一个连通图。产生连通图的常见的方法有克鲁斯卡尔算法和普里姆算法，这里我们以普里姆算法为例实现，使用普里姆算法产生的迷宫比较自然和随机。

使用普里姆算法产生迷宫的具体步骤如下。

（1）图6-23所示为一个6×6的迷宫，先假设迷宫中所有的通路都是完全封闭的，白色的格子表示可以通过，黑色的表示墙壁，表示无法通过。

图6-23 完全封闭的格子

（2）随机选择一个白色的格子作为当前正在访问的格子，同时，把该格子放进一个表示已经访问的列表。

（3）循环以下操作直到所有的格子都被访问。

• 得到当前访问格子四周（上、下、左、右）的格子，在这些格子中随机选择一个没有在访问列表中的格子，如果找到，则把该格子和当前访问格子中间的墙"打通"置0，把该格子作为当前访问的格子，并放入访问列表。

• 如果周围所有的格子都已访问过，则从已访问列表中随机选取一个作为当前访问的格子。

通过以上的迷宫生成算法，可以生成一个自然随机的迷宫。

下面的代码根据以上的算法将产生一个R行N列大小的迷宫，需要注意的是R行表示的是刚开始空白格子的行数，由于要算上墙壁的数据，最终产生二维数组实际上的的行数为2R+1，列数为2N+1：

primMaze:function(r, c)

{

//初始化数组

function init(r, c)

{

var a=new Array(2*r+1);

//全部置1

for(var i=0, len=a.length;i<len;i++)

{

var cols=2*c+1;

a[i]= new Array(cols);

ArrayUtil.fillWith(a[i], 1);

}

//中间格子为0

for(var i=0;i<r;i++)

for(var j=0;j<c;j++)

{

a[2*i+1][2*j+1]=0;

}

return a;

}

//处理数组，产生最终的数组

function process(arr)

{

//acc存放已访问队列，noacc存放没有访问队列

var acc = [], noacc = [];

var r = arr.length>>1, c=arr[0].length>>1;

var count = r*c;

for(var i=0;i<count;i++){noacc[i]=0;}

//定义空单元上下左右偏移

var offs=[-c, c, -1, 1], offR=[-1, 1, 0, 0], offC=[0, 0, -1, 1];

//随机从noacc取出一个位置

var pos = MathUtil.randInt(count);

noacc[pos]=1;

acc.push(pos);

while(acc.length<count)

{

var ls=-1, offPos=-1;

offPos = -1;

//找出pos位置在二维数组中的坐标

var pr = pos/c|0, pc=pos%c, co=0, o=0;

//随机取上下左右四个单元

while(++co<5)

{

o=MathUtil.randInt(0, 5);

ls =offs[o]+pos;

var tpr = pr+offR[o];

var tpc = pc+offC[o];

if(tpr>=0&&tpc>=0&&tpr<=r-1&&tpc<=c-1&&noacc[ls]==0){ offPos = o;break;}

}

if(offPos<0)

{

pos = acc[MathUtil.randInt(acc.length)];

}

else

{

pr=2*pr+1;

pc = 2*pc+1;

//相邻空单元中间的位置置0

arr[pr+offR[offPos]][pc+offC[offPos]]=0;

pos = ls;

noacc[pos] = 1;

acc.push(pos);

}

}

}

var a = init(r, c);

process(a);

return a;

}

图6-24所示就是使用以上算法产生的迷宫，有了迷宫之后，接下来我们看看在路径搜索中常用的A*算法。

图6-24 随机产生的迷宫

6.3.2 A*算法

通常情况下，迷宫寻路算法可以使用深度优先或者广度优先算法，但由于效率原因，不会直接使用这些算法，在路径搜索算法中最常见的就是A*寻路算法。使用A*算法的魅力之处在于它不仅能找到地图中从A到B的一条路径，还能保证找到的是一条最近的路径，它是一种常见的启发式搜索算法，类似于 Dijkstra 算法一样的最短路径查找算法，很多游戏应用中的路径搜索基本都是采用这种算法或者是A*算法的变种。

接下来，我们来看看具体A*算法相关的理论知识。

如图6-25所示，我们需要在迷宫中找到从点A到点B的一条最短的可以通过的路径，A和B之间有一堵墙挡住了。

图6-25 被一堵墙挡住的A 和B

在图6-25中，正如前面所说，地图实际上是由二维数组组成，墙表示不能通过的地方，用 1 表示，A*算法所要做的就是从A找到一条最短的通向B的路径，当然，不能从墙上飞过去，也不能瞬移到B。饭要一口一口地吃，路要一步一步地走，也就是说，每次我们只能移动一个格子，一步一步地移动到 B 目标位置。问题在于，每次移动一格的时候有上下左右四个方向，这里我们限制物体斜向移动，如何选择下一个移动方向呢？按照我们的想法，不就是找一条离目标点最近的路吗？那我们就可以在这四个可移动的方向中，找一个最接近目标点的位置，当然，还要考虑障碍因素，基于这个思想，A*算法采用以下搜索步骤实现。

（1）首先把起始位置点加入到一个称为"open List"的列表，在寻路的过程中，目前，我们可以认为open List这个列表会存放许多待测试的点，这些点是通往目标点的关键，以后会逐渐往里面添加更多的点进行测试，同时，为了效率考虑，通常这个列表是个已经排序的列表。

（2）如果open List列表不为空，则重复以下工作。

• 找出open List 中通往目标点代价最小的点作为当前点。

• 把当前点放入一个称为close List的列表。

• 对当前点周围的 4 个点每个进行处理（这里限制了斜向移动），如果该点是可以通过并且该点不在close List中，则处理如下。

• 如果该点正好是目标点，则把当前点作为该点的父节点，并退出循环，设置已经找到路径标记。

• 如果该点也不在 open List 中，则计算该节点到目标节点的代价，把当前点作为该点的父节点，并把该点添加到open List中。

• 如果该点已经在 open List 中了，则比较该点和当前点通往目标点的代价，如果当前点的代价更小，则把当前点作为该点的父节点，同时，重新计算该点通往目标点的代价，并把open List重新排序。

（3）完成以上循环后，如果已经找到路径，则从目标点开始，依次找每个节点的父节点，直到找到开始点，这样就形成了一条路径。

好了，以上就是关于A*算法的全部的步骤，按照这个步骤，就可以的到一个正确的路径。这里有一个关键的地方，就是如何计算每个点通往目标点的代价，之所以称A*算法为启发式搜索，就是因为通过评估这个代价值来搜索最近的路径，对于任意一个点的代价值，在A*算法中通常使用下列公式计算：

在这里，F表示通往目标点的代价，G表示从起始点移动到该点的距离，H则表示从该点到目标点的距离，比如我们把图6-25取出放大，得到图6-26，在图中可以清晰的看到起始点A上下左右4个相邻点的代价值，其中左上角的数字表示F值，左下角的数字表示G值，右下角的数字表示H值。我们拿A点上方的格子举例，G=1，表示从A到该点的距离为1个格子，H=5，表示从该点到B点的距离是5个格子，则F=G+H为6。在此处，距离的算法是采用曼哈顿距离，它计算从当前格子到目的格子之间水平和垂直的方格的数量总和，例如在平面上，坐标（x1， y1）的点P1与坐标（x2，y2）的点P2的曼哈顿距离为：

当然，距离的算法也可采用其他方法，实际在游戏中，这个移动的代价除了要考虑距离因素外，还要考虑当前格子的游戏属性。比如有的格子表示水路、草地、陆地，这些有可能影响人物移动的速度，实际计算的时候还要把这些考虑在内。

另一个需要注意的是，在计算这个距离的时候是无需考虑障碍因素的，因为在以上A*算法步骤中会剔除掉障碍。

这样，按照前面所说的A*算法的步骤，第一次循环openList的时候，把A作为当前点，同时把A 周围的四个点放入open List中。第二次循环的时候把A右边的点作为当前点，该点的父节点是就是A，这时处理当前点的时候，只需要把当前点的上下两个点放入open List中，因为左边的A 已经在close List 中，而右边的是墙，所以直接被忽略。图6-27 则展示了第二次循环的代价图。

图6-26 起始点A周围4个点的代价

图6-27 第二次循环的代价图

很明显，下次循环的时候A点右斜下的点就作为当前点处理了，黑色箭头表示该节点指向父节点。这样，采用A*算法循环完open List后，最后就可以通过最终点B 点依次查找每个节点的父节点形成一条最短路径。

根据以上A*算法的步骤，给出了以下代码作为参考：

//地图工具类

var _MapUtil = win.MapUtil =

{

//定义点对象

Point:function(x, y)

{

this.x=x;

this.y = y;

this.parent = null;

this.f = 0;

this.g = 0;

this.h=0;

//当前点状态，0表示在openlist，1表示closelist，-1表示还没处理

this.state=-1;

//flag表明该点是否可通过

this.flag = 0;//flag indicate whether this point can go 0:can go 1:

},

//把普通二维数组（全部由1和0表示）的转换成A*所需要的点数组

convertArrToAS:function(arr)

{

var r=arr.length, c=arr[0].length;

var a = new Array(r);

for(var i=0;i<r;i++)

{

a[i]=new Array(c);

for(var j=0;j<c;j++)

{

var pos=new MapUtil.Point(i, j);

pos.flag = arr[i][j];

a[i][j]=pos;

}

}

return a;

},

//A*算法，pathArr表示最后返回的路径

findPathA:function(pathArr, start, end, row, col)

{

//添加数据到排序数组中

function addArrSort(descSortedArr, element, compare)

{

var left=0;

var right = descSortedArr.length-1;

var idx = -1;

var mid = (left+right)>>1;

while(left<=right)

{

var mid=(left+right)>>1;

if(compare(descSortedArr[mid], element)==1)

{

left=mid+1;

}

else if(compare(descSortedArr[mid], element)==-1)

{

right=mid-1;

}

else

{

break;

}

}

for(var i = descSortedArr.length-1;i>=left;i--)

{

descSortedArr[i+1]=descSortedArr[i];

}

descSortedArr[left]=element;

return idx;

}

//判断两个点是否相同

function pEqual(p1, p2)

{

return p1.x==p2.x&&p1.y==p2.y;

}

//获取两个点距离，采用曼哈顿方法

function posDist(pos, pos1)

{

return (Math.abs(pos1.x-pos.x)+Math.abs(pos1.y-pos.y));

}

function between(val, min, max)

{

return (val>=min&&val<=max)

}

//比较两个点f值大小

function compPointF(pt1, pt2)

{

return pt1.f-pt2.f;

}

//处理当前节点

function processCurrpoint(arr, openList, row, col, currPoint, destPoint)

{

//get up, down, left, right direct

var ltx = currPoint.x-1;

var lty = currPoint.y-1;

for(var i=0;i<3;i++)

for(var j=0;j<3;j++)

{

var cx = ltx+i;

var cy = lty+j;

if((cx==currPoint.x||cy==currPoint.y)&&between(ltx, 0, row-1)

&&between(lty, 0, col-1))

{

var tp = arr[cx][cy];

if(tp.flag == 0 && tp.state!=1)

{

if(pEqual(tp, destPoint))

{

tp.parent = currPoint;

return true;

}

if(tp.state == -1)

{

tp.parent = currPoint;

tp.g= 1+currPoint.g;

tp.h= posDist(tp, destPoint);

tp.f = tp.h+tp.f;

tp.state = 0

addArrSort(openList, tp, compPointF);

}

else

{

var g = 1+currPoint.g

if(g<tp.g)

{

tp.parent = currPoint;

tp.g = g;

tp.f = tp.g+tp.h;

openList.quickSort(compPointF);

}

}

}

}

}

return false;

}

//定义openList

var openList = [];

//定义closeList

var closeList = [];

start = pathArr[start[0]][start[1]];

end = pathArr[end[0]][end[1]];

//添加开始节点到openList

addArrSort(openList, start, compPointF);

var finded = false;

var tcount = 0;

while((openList.length>0))

{

var currPoint = openList.pop();

currPoint.state = 1;

closeList.push(currPoint);

finded = processCurrpoint(pathArr, openList, row, col, currPoint, end);

if(finded)

{

break;

}

}

if(finded)

{

var farr = [];

var tp = end.parent;

farr.push(end);

while(tp!=null)

{

farr.push(tp);

tp = tp.parent;

}

return farr;

}

else

{

return null;

}

}

}

这里，我们专门定义了一个MapUtil的工具类用于处理和地图相关的操作，该类的源码在xengine中的xutil.js文件中。

图6-28是利用前面所学的算法实现的一个简单的例子，用户可以在迷宫中点击任意地点，蓝色的球就会自动移动到该地点。

图6-28 利用A*算法完成的迷宫图

关于路径搜索的基本算法了解到这里，A*算法不仅可以用在游戏当中，同样也可以应用到其他领域，比如车辆定位和行车自动导航，当然，这得需要另外的地理信息数据做支持。

接下来，来看看游戏中关于状态机的应用。

6.4 有限状态机

在我们玩游戏中经常会碰到一些NPC似乎具有一定的智能行为，比如在魔兽中，敌人会根据主角的状态发动攻击、躲避、甚至引诱等相应行为，在FIFA比赛中，球员会进行运球、拦截以及射门等。总之，在人和电脑之间的对抗过程中，电脑的AI越来越复杂和高级，通常对NPC的行为的管理和控制中，有限状态机的作用功不可没。

6.4.1 原理

有限状态机在维基百科中的解释如下：有限状态机（finite-state machine，FSM），又称有限状态自动机，简称状态机，是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。

按照以上的简单描述，我们可以理解为任何一个物体都会有多个有限的行为状态，这些行为状态之间可能会因为某些条件而发生相互之间的转换。事实上，关于状态机，我们可以使用状态图来展示，我们经常会使用状态图来描述一些理论知识的应用，比如对多线程使用状态图描述，图6-29所示的线程5态的转换图。

图6-29中使用UML的状态图描述线程的状态描述图，由图可以看出线程具有5个状态，分别是开始状态、就绪状态、运行状态、阻塞状态，以及结束。状态和状态之间的转换使用实线连接，箭头表示状态移动的方向，状态连接线上描述的是触发状态转移的条件。

图6-29 典型的线程5态图

如果对于一个简单的灯的开关来说，可以用图6-30来进行描述。

图6-30 开关灯的状态图

状态图其实就是对状态机的图形化的一个展现，除了使用状态图来描述有限状态机外，还可以使状态表的形式进行描述，如表 6-1所示的线程状态表。

表6-1 线程状态表

表6-1展示了使用状态表描述线程的状态改变，二维表的表头和每行第一列描述的是线程所有的状态，状态和状态之间的交叉格描述的是触发状态改变的事件，比如触发就绪到运行的事件是CPU运行时间到，如果状态之间无法转变，则用×表示。

6.4.2 实现

关于有限状态机最简单的实现就是使用多个if和else语句了，或者使用switch case 语句，比如对于前面的灯的开关，就可以通过以下代码实现:

switch(Light.state)

{

case'on':

if(pressOffButton()) Light.state = 'off'

break;

case 'off':

if(pressOnButton()) Light.state = 'on'

break;

}

对于这个简单的例子，好像还运行得不错，但是一旦随着问题的复杂性增加，物体的状态增多，状态条件和事件的增多，这里的代码就需要经常的变更，导致冗长的switch和case语句，增加了更多的维护成本。要知道，软件产品大部分的成本在维护上面，所以，创建一个相对容易维护的状态机系统远比使用更多的条件判断语句重要得多。

为了实现对状态机更容易的维护和管理，事实上在 GOF 的设计模式中提供了对于面向对象的有限状态机的设计，即状态模式，图6-31展示了状态模式的UML图例。

图6-31涉及了这样几个类，一个是StateContext，称为状态环境类，实际上就是拥有状态对象的类。该类充当状态管理器角色，在环境类中对状态进行切换操作。一个是State抽象类，是所有具体State实现类的父类。ConcreateState1和ConcreateState2分别是State的子类，封装了具体的状态活动实现。由此可见，使用状态模式将特定的状态活动行为封装到一个对象中，具体的状态活动的实现延迟到了子类中，通过定义新的状态子类来扩展状态对象，这样就避免了大量的条件分支语句，带来的好处就是维护的方便性。

图6-31 UML 状态模式

根据有限状态机的定义描述，实际上，我们可以把有限状态机分解成4个组成部分，即：状态、事件、转换和活动，每个状态通过一个事件触发而转换到其他状态，在转换的过程中，每个状态有进入动作和退出动作，活动表示物体处于当前状态下进行的动作。

接下来，来看一个简单的状态机的实现，首先，我们定义一个抽象的状态类，代码如下：

//状态抽象类

var _state = win.State = Class.extend({

init:function(type, context)

{

this.ctx=context;

this.type = type;

},

//调用context changeState 方法转换到其他状态

change:function()

{

return;

},

//当前状态的活动

update:function()

{

return;

},

//进入该状态时执行

enter:function()

{

return true;

},

//退出状态执行

exit:function()

{

return;

}

})

可以看到，该抽象的状态类有几个基本方法的默认实现，这几个基本方法对应描述有限状态机的4个组成部分，另外，包含两个基本属性，ctx表示该状态类的状态机控制对象，type是用字符串描述的状态类型。

有了抽象状态类后，接下来看状态环境类，也就是状态管理类的实现：

//状态管理类

var _StateContext = win.StateContext = Class.extend({

init:function(owner)

{

//和该状态类关联的对象

this.owner = owner;

this.states={};

this.currState = null;

},

changeState:function(type)

{

this.currState&&this.currState.exit();

this.currState = this.states[type];

this.currState.enter();

},

getState:function(type)

{

return this.states[type];

},

//注册一个状态类

regState:function(state)

{

this.states[state.type]=state;

return this;

},

//注销一个状态类

unRegState:function(type)

{

if(this.states[type]!=null&&this.currState!=this.states[type])

{

delete this.states[type];

}

},

unRegAll:function()

{

this.currState=null;

this.states = {};

},

//该方法在游戏主循环中使用

update:function()

{

this.currState.change();

this.currState.update();

}

})

时刻记住一点，状态环境类是用来维护和调度状态之间变迁的一个管理类，状态环境类中有3个基本的属性：states属性会保存所有不同类型的状态，currState表示当前物体的状态，owner则表示和当前状态机密切关联的物体，也就是该状态机属于管理哪个物体的状态。

除了属性外，StateContext还包含了注册以及注销状态机的方法，切换到另一个状态的方法，以及update更新方法。update方法主要用在游戏循环中，还记得每一个Sprite有一个update方法吗？通常情况下，我们会对每一个Sprite使用状态机进行管理，在Sprite的update中调用状态机的update方法，使用状态机管理和控制Sprite在游戏中的状态。

好了，基本的状态机框架搭建到这里，接下来，我们来看一下如何把有限状态机应用到一个游戏中去。

6.4.3 案例：勤劳的蚂蚁

这里，我们借用一个经典的案例，勤劳的蚂蚁，很多关于游戏 AI 的书中都以此为经典，就好比线程同步和死锁总拿哲学家的例子解说一样，对于一个有限状态机的应用来说，这个例子显得简单而有趣。

整个游戏的情景是这样描述的：有一群勤劳的蚂蚁，从诞生下来就得到处寻找食物，找到食物之后就回到巢穴，回到巢穴后，由于有了充足的食物，巢穴中又会诞生新的蚂蚁。当然，这只可怜的蚂蚁也得出去找食物，回到巢穴的蚂蚁还得出去找水，找到水后，继续找食物，回巢穴，基本就是三点一线的生活。在蚂蚁到处找东西的过程中还有可能碰到毒药，结果就会被召回天堂。

根据前面情景的描述，基本可以确定，蚂蚁在整个游戏中的状态只有4个，找食物、回家、找水以及死亡，由此得到图6-32所示的状态图。

图6-32 勤劳的蚂蚁状态图

图6-33则显示了这个游戏的截图。

接下来，我们看看如何结合引擎和有限状态机来实现这个简单而有趣的游戏。

首先，这个游戏的重点在蚂蚁上面，蚂蚁的行为完全通过状态机来控制，所以这个类需要从Sprite继承定制，所以得到最初的代码如下：

图6-33 勤劳的蚂蚁游戏截图

//从引擎的RenderObj继承

Ant = Sprite.extend({

init:function()

{

this.sCtx = new StateContext(this);

this._super();

}

}

Ant从Sprite继承，添加了一个状态管理器属性，接下来，以找食物为例，给Ant添加找食物状态类，代码如下：

var FFoodState = State.extend({

change:function()

{

if(this.ctx.owner.isFindFood())

{

//回家

this.ctx.changeState("goHome");

}

else if(this.ctx.owner.isFindPoison())

{

//中毒死亡

this.ctx.changeState("goDead");

}

},

update:function()

{

this.ctx.owner.randMove();

}

});

FfoodState从State类继承而来，复写了父类的两个最主要的方法，一个是change方法，该方法用于转移到其他状态，这个方法中首先通过this.ctx.owner获取到状态管理器的拥有者（一只蚂蚁对象），然后通过isFindFood方法判断是否寻找到食物，如果找到，则调用状态管理器的changeState方法，转换到回家状态，如果找到了毒物，则转换到死亡状态。

另一个复写的方法是update方法，该方法表示，如果蚂蚁处于找食物状态中所进行的活动，此时，蚂蚁处于一个随机移动的状态，定义好了蚂蚁状态类之后，则需要通过状态管理器的regState方法注册状态类，最后Ant的全部代码类如下：

/*

* Ant 类

*/

(function(){

//从引擎的RenderObj继承

Ant = Sprite.extend({

init:function()

{

this.sCtx=new StateContext(this);

this.regState();

this.sCtx.changeState("goFood");

this._super();

},

//注册蚂蚁状态类

regState:function()

{

this.sCtx.regState(new FFoodState("goFood", this.sCtx));

this.sCtx.regState(new FHomeState("goHome", this.sCtx));

this.sCtx.regState(new FWaterState("goWater", this.sCtx));

this.sCtx.regState(new FDeadState("goDead", this.sCtx));

},

//下个位置是否可移动

testMove:function(nx, ny)

{

var p=TGame.map.mapSCPos(nx, ny);

return !TGame.map.isBar(p[0], p[1]);

},

//是否找到目标

isFindTarget:function(mFlag)

{

//获取当前位置

var p = TGame.map.mapSCPos(this.x, this.y);

var o = null;

if((o=TGame.map.getMObj(p[0], p[1]))!=null)

{

if(o.mFlag==mFlag)

{

TGame.removeMSprite(p[0], p[1]);

return true;

}

}

return false;

},

//是否碰到食物

isFindFood:function()

{

return this.isFindTarget(Map.SFlag.FOOD);

},

isFindHome:function()

{

return this.x==TGame.nest[0]&&this.y==TGame.nest[1]

},

isFindWater:function()

{

return this.isFindTarget(Map.SFlag.WATER);

},

isFindPoison:function()

{

return this.isFindTarget(Map.SFlag.POISON);

},

//随机移动

randMove:function()

{

var dy = [-32, 32, 0, 0], dx=[0, 0, -32, 32];

var o = MathUtil.randInt(4);

var nx = this.x+dx[o];

var ny = this.y+dy[o];

if(this.testMove(nx, ny))

{

this.moveTo(nx, ny);

}

},

//重写update

update:function()

{

this.sCtx.update();

}

});

Ant.ClassName = "Ant";

//定义蚂蚁各种状态类

var FFoodState = State.extend({

change:function()

{

if(this.ctx.owner.isFindFood())

{

//回家

this.ctx.changeState("goHome");

}

else if(this.ctx.owner.isFindPoison())

{

//中毒死亡

this.ctx.changeState("goDead");

}

},

update:function()

{

this.ctx.owner.randMove();

}

});

var FHomeState = State.extend({

change:function()

{

if(this.ctx.owner.isFindHome())

{

//产生新蚂蚁

TGame.createSprite(1, Map.SFlag.ANT, "ant");

//找水

this.ctx.changeState("goWater");

}

else if(this.ctx.owner.isFindPoison())

{

//中毒死亡

this.ctx.changeState("goDead");

}

},

update:function()

{

if(this.ctx.owner.x!=TGame.nest[0])

this.ctx.owner.x+=(this.ctx.owner.x<TGame.nest[0])?32:-32;

if(this.ctx.owner.y!=TGame.nest[1])

this.ctx.owner.y+=(this.ctx.owner.y<TGame.nest[1])?32:-32;

}

});

var FWaterState = State.extend({

change:function()

{

if(this.ctx.owner.isFindWater())

{

//找食物

this.ctx.changeState("goFood");

}

else if(this.ctx.owner.isFindPoison())

{

//中毒死亡

this.ctx.changeState("goDead");

}

},

update:function()

{

this.ctx.owner.randMove();

}

});

var FDeadState = State.extend({

change:function()

{

return;

},

update:function()

{

var s=this.ctx.owner;

s.owner.removeRObj(s);

}

});

//注册蚂蚁类

ClassFactory.regClass(Ant.ClassName, Ant);

}())

除了定义蚂蚁类之外，还需要定义一个Map类，用于处理地图，在本节例子中的地图相对简单很多，地图数据同样采用二维数组的形式表示，整个Map类的代码如下：

/*

* 地图

*/

(function(){

//定义地图块对象

function MPoint(r, c, flag)

{

//行

this.r = r;

//列

this.c = c;

//通过标记

this.flag = flag;

//地面物体

this.mObj = null;

}

//从引擎的RenderObj继承

Map = Sprite.extend({

init:function(r, c, wName, ww, wh)

{

this.r=r;

this.c = c;

this.ww = ww;

this.wh = wh

this.wName = wName;

this.offX = 0;

this.offY = 0;

this.mapData = null;

//闲置地块数组

this.nullF = [];

this._super();

},

//地图复位

reset:function()

{

var sc=this.owner;

var r = this.r*2+1;

var c = this.c*2+1;

var mapData =new Array(r);

this.mapData = mapData;

this.offX = (sc.w-this.ww*(2*this.c+1))*0.5+(this.ww*0.5)

this.offY = (sc.h-this.wh*(2*this.r+1))*0.5+(this.wh*0.5);

//创建地图

for(var i=0;i<r;i++)

{

mapData[i]=new Array(c);

for(var j=0;j<c;j++)

{

mapData[i][j]=new MPoint(i, j, 0);

if(i==0||i==r-1||j==0||j==c-1)

{

mapData[i][j].flag=1;

}

if(mapData[i][j].flag==1)

{

var bar=sc.createRObj(Sprite.ClassName);

var anims = ResManager.getAnimationsByName("sprite", this.wName);

bar.setAnims(anims);

bar.w = this.ww;

bar.h = this.wh;

bar.moveTo(this.offX+j*this.ww, this.offY+i*this.wh);

}

else

{

//放入闲置地表中，存放索引号

this.nullF.push(i*c+j);

}

}

}

},

//定位屏幕坐标到数组位置

mapSCPos:function(x, y)

{

return[(y-this.offY+this.wh*0.5)/this.wh|0,(x-this.offX+this.ww*0.5)/this.ww|0];

},

//定位数组位置到屏幕坐标

mapPosToSC:function(r, c)

{

return [this.offX+this.ww*c|0, this.offY+this.wh*r|0];

},

//随机找一个闲置的地表，可放置物体

getRandNull:function()

{

var idx=this.nullF[MathUtil.randInt(this.nullF.length-1)];

var c = (this.c<<1)+1;

return [idx/c|0, idx%c];

},

//获取r行c列地块在数组中的索引号

getArrIdx:function(r, c)

{

var mr=(this.r<<1)+1, mc=(this.c<<1)+1;

return r*mc+c;

},

//设置地面物体

setMObj:function(r, c, mObj)

{

var md=this.mapData[r][c];

if(!md.mObj&&md.flag==0)

{

md.mObj=mObj;

}

//从闲置地表移出

var idx = this.getArrIdx(r, c);

ArrayUtil.removeFn(this.nullF, function(i){return i==idx});

},

//清除地面物体

clearMObj:function(r, c)

{

if(this.mapData[r][c].mObj!=null)

{

this.mapData[r][c].mObj=null;

var idx = this.getArrIdx(r, c);

this.nullF.push(idx);

}

},

//获取地面物体

getMObj:function(r, c)

{

return this.mapData[r][c].mObj;

},

//获取地图障碍标记

isBar:function(r, c)

{

return this.mapData[r][c].flag==1;

},

render:function(ctx)

{

return true;

}

});

Map.ClassName = "Map";

//地面物体标记

Map.SFlag = {

ANT:0,

WATER:1,

FOOD:2,

NEST:3,

POISON:4

}

//注册CObj类

ClassFactory.regClass(Map.ClassName, Map);

}())

整个地图类通过new Map (r, c, wName, ww, wh)调用，r、c 分别表示地图的行数和列数，需要注意的是，和前面寻路算法一样，真正的数组行数为2r+1，列数为2c+1。wName表示地图中墙对应的动画资源，对于地图中的每一个地图块都用一个MPoint对象描述，该对象存储了每个地图块的行和列号，以及是否可以通过的标记。还有一个 mObj 对象用于保存一个指向该地图块上面放置的物体。对于这个简单的游戏来说，一个地图块上面任何一个时刻最多只放置一个物体，对于复杂的游戏来说可能会存在一组物体。

最后，来看看游戏主体部分，和之前使用的游戏引擎一样，该实例直接从Game类继承，代码如下：

(function(win){

var _Game = Game.extend({

map:null,

nest:[0, 0],

init:function()

{

this._super();

var scm = this.sceneManager;

//创建场景

var x = (document.body.clientWidth-800)*0.5;

var sc = scm.createScene([{"x":x, "w":800, "h":600, "name":"main"}]);

//设置场景背景

sc.setBGImg("../../imgs/bk2.png", 2);

this.loadRes(sc);

},

//加载资源

loadRes:function(scene)

{

var self=this;

ResManager.loadRes("data/res.json", function(){

//创建精灵

self.initSprite();

//开始飞奔

self.run(10);

});

},

//移出地图上物体

removeMSprite:function(r, c)

{

var sc=this.sceneManager.getScene("main");

var obj = this.map.getMObj(r, c);

if(obj)

{

this.map.clearMObj(r, c);

sc.removeRObj(obj.mObj);

}

},

//随机产生几个对象

createSprite:function(num, mFlag, animName)

{

var sc=this.sceneManager.getScene("main");

for(var i =0;i<num;i++)

{

var sprite=null;

if(mFlag==Map.SFlag.ANT)

{

sprite=sc.createRObj(Ant.ClassName);

}

else

{

sprite=sc.createRObj(Sprite.ClassName);

}

sprite.setAnims(ResManager.getAnimationsByName("sprite", animName));

var p = this.map.getRandNull();

var p1 = this.map.mapPosToSC(p[0], p[1]);

sprite.moveTo(p1[0], p1[1]);

if(mFlag==Map.SFlag.NEST)

{

this.nest=p1;

}

this.map.setMObj(p[0], p[1], {"mFlag":mFlag, "mObj":sprite});

}

},

initSprite:function()

{

var sc=this.sceneManager.getScene("main");

var r = 9, c=11;

//创建地图

this.map = sc.createRObj(Map.ClassName, [r, c, "bar", 32, 32]);

this.map.reset();

//创建水

this.createSprite(10, Map.SFlag.WATER, "water");

//创建食物

this.createSprite(16, Map.SFlag.FOOD, "food");

//创建毒物

this.createSprite(6, Map.SFlag.POISON, "poison");

//创建巢穴

this.createSprite(1, Map.SFlag.NEST, "nest");

//创建蚂蚁

this.createSprite(10, Map.SFlag.ANT, "ant");

}

})

//定义全局TGame

win.TGame = new _Game();

}(window))

TGame类的基本结构和前面的例子类似，基本上的流程就是，创建场景，添加精灵到场景中，最后让游戏运行起来。

到此为止，关于有限状态机在游戏中的应用部分就介绍到这里，有限状态机的应用很广泛，本例中，只是用它来管理游戏中的精灵部分，在很多游戏中还会使用它来管理整个游戏的状态。很多时候，可以把一个游戏从开始运行到结束的整个生命周期分解成很多状态，这时也可以运用有限状态机来管理。

6.5 粒子系统

粒子系统本身来说不属于 AI 部分的算法，应该是属于渲染部分的算法，其中也涉及一些简单的物理学知识，由于粒子系统也经常用在游戏中，所以把这个算法也划分到本章中。

粒子系统通常用在游戏的特效部分，经常使用粒子系统模拟的现象有火、爆炸、烟、水流、火花、落叶、云、雾、雪、尘、流星尾迹或者像发光轨迹这样的抽象视觉效果等。如图6-34所示的几种粒子效果图，当然，这些常用的粒子特效虽然让游戏显得更为真实和有效，但过多的使用粒子特效会降低游戏运行的效率，所以很多情况下会酌情考虑粒子的使用，特别是在HTML5效率还不是很高的情况下。

图6-34 粒子系统产生的烟花

粒子效果，顾名思义，本质上是有很多很多细小的粒子形成的特效，一两个粒子还看不出来效果，但量变引起质变，当粒子的数量多到一定程度，就会形成可观的效果。

粒子系统本质上就是由大量的、具有一定的生命周期和属性的、无固定形态的、细小的图元组成，为了模拟不同的实现效果，粒子的属性是不一样的，这需要根据具体情况而定。但基本上具有形状、大小颜色、生命以及速度和加速度这些最基本的属性，而且，这些属性基本上都是和时间相关的，当生命结束的时候，粒子也就消失了，也就是说，粒子一般都会经过产生、活动以及消亡的过程。

粒子的产生通常使用粒子发生器，粒子发生器的主要目的是在短时间内产生大量的粒子，根据具体模拟效果的需要，给这些粒子设定相关的属性，比如初速度、加速度以及生命的长短等，在需要的时候可以通过粒子发生器产生粒子。实际生活中常用的烟花、婚宴的礼花等就是一个个典型的粒子发生器。

就粒子的实现来说，渲染的时候采用以下步骤。

（1）使用粒子发生器产生粒子，粒子进入产生阶段。

（2）更新粒子的属性，比如年龄、颜色、大小和速度等。

（3）如果粒子的年龄超过了生命最大值，则删除该粒子。

（4）渲染粒子。

现在，来看一个简单的粒子效果的实现，该粒子类作为其他粒子的父类，由于速度和加速度在Sprite中已经存在，这里直接继承至Sprite，包含了关于粒子的最基本的一些属性，代码如下：

//从引擎的Sprite继承

Particle = Sprite.extend({

init:function()

{

//生命毫秒

this.life = 500;

//年龄

this.age = 0 ;

//开始时间

this.sTime = 0 ;

//初始大小

this.sSize = 1;

//结束大小

this.eSize = 1;

//是否激活

this.active = false;

this._super();

this.isVisible = this.active;

},

start:function()

{

this.sTime=FrameState.currTime;

this.active = this.isVisible = true;

},

stop:function()

{

this.active=this.isVisible=false;

this.age = 0;

},

update:function()

{

if(this.active)

{

this.age=FrameState.currTime-this.sTime;

if(this.age>=this.life){

this.owner.removeRObj(this);

return ;

}

this._super();

}

}

});

在设计粒子基类的时候，根据需要设定了粒子的常用的属性，复写了update方法，在update方法中判断如果粒子的当前年龄大于生命极限则删除粒子。

这里没有粒子的渲染方法，具体的渲染过程交给子类实现，以下是一个把圆形作为基本粒子展现的实现：

CirParticle = Particle.extend({

init:function(r, c)

{

this.r=r;

this.color = c||[255, 255, 255, 1];

this._super();

},

render:function(ctx)

{

//获取生命比例

var lratio = this.age/this.life;

var sc = this.sSize+(this.eSize-this.sSize)*lratio;

this.scaleX = this.scaleY = sc;

//更新颜色

var r = MathUtil.lerp(this.color[0], 0, lratio)|0,

g = MathUtil.lerp(this.color[1], 0, lratio)|0,

b = MathUtil.lerp(this.color[2], 0, lratio)|0;

this.color[3] = (1-lratio)*0.5;

ctx.fillStyle = ColorUtil.rgb(r, g, b);

ctx.translate(this.x, this.y);

ctx.scale(this.scaleX, this.scaleY);

ctx.globalAlpha = this.color[3];

ctx.beginPath();

ctx.arc(0, 0, this.r, 0, Math.PI*2);

ctx.fill();

ctx.closePath();

}

});

这个类主要负责的是粒子的渲染，在创建该类型粒子的时候，需要两个属性，半径和颜色。在具体实现渲染的时候，首先计算出年龄在整个生命周期中的比例，然后根据这个比例调整粒子的大小、颜色以及透明度，也就是说，本例中认为这些属性和年龄呈线性关系，实际运用当中，这些属性和年龄有可能是其他函数关系。

接下来看粒子发生器的实现，前面说了，粒子发生器的主要任务是创建大量的、随机运动的粒子，因此，我们得到了下面的实现代码：

//粒子发生器

Emit = Class.extend({

init:function(param, sc, pClassName, pParam)

{

//喷射间隔时间

this.speed = param["speed"]||100;

//喷射次数，<0表示无限

this.times = param["times"]||-1;

//设定生命周期

this.lifeRange = param["lifeRange"]||[500, 800];

this.sSize = param["sSize"]||1;

this.eSize = param["eSize"]||1;

this.timeRange = param["timeRange"]||999999;

this.maxNum = param["maxNum"]||200;

this.pos = param["pos"]||[0, 0]

this.v = param["v"]||[0, 0];

this.vRange = param["vRange"]||[0, 0];

this.a = param["a"]||[0, 0];

this.aRange = param["aRange"]||[0, 0];

this.sc = sc;

this.pClassName = pClassName;

this.pParam = pParam;

this.startTime = FrameState.currTime;

},

createParticle:function()

{

for(var i=0;i<this.maxNum;i++)

{

var p=this.sc.createRObj(this.pClassName, this.pParam);

p.life = MathUtil.randInt(this.lifeRange[0], this.lifeRange[1]);

p.sSize = this.sSize;

p.eSize = this.eSize;

p.x = this.pos[0];

p.y = this.pos[1];

p.vx = this.a[0]+MathUtil.randRange(this.aRange[0]);

p.vy = this.a[1]+MathUtil.randRange(this.aRange[1]);

p.dx = this.v[0]+MathUtil.randRange(this.vRange[0]);

p.dy = this.v[1]+MathUtil.randRange(this.vRange[1]);

p.start();

}

},

//发射

jet:function()

{

this.startTime = FrameState.currTime;

this.createParticle();

},

//可放在游戏循环中更新

update:function()

{

if((this.times!=0)&&(FrameState.currTime-this.startTime>this.speed))

{

this.jet();

}

if(this.times>0)--this.times;

}

})

粒子发生器的初始化参数比较多，但这些参数主要是为了在产生粒子的时候，让这些粒子具有随机性，比如lifeRange表示粒子生命周期的范围大小，产生的粒子生命在这个范围之内， maxNum表示一次性产生粒子的数量，vRange表示产生粒子的初始速度大小范围，aRange则是加速度的范围。一旦设定了粒子发生时候的参数，就可以通过发生器的jet方法，根据初始的参数产生相应的粒子，以下代码则创建了一个粒子发生器：

var emit = new Emit({

"lifeRange":[600, 900],

"sSize":1,

"eSize":0.1,

"maxNum":40,

"pos":[100, 100],

"v":[0, -1.6],

"vRange":[0.6, 1],

"a":[0, 0.01],

"aRange":[0.01, 0.01]

}, sc, CirParticle.ClassName, [5, [255, 220, 60, 1]]);

粒子效果的实现就到这里，关于粒子的使用还需要注意以下两点。

（1）粒子效果有可能需要一些编辑器来调整粒子的属性，这样会更加的直观和方便。

（2）粒子效果可能会使CPU占用率剧烈波动，会影响游戏的稳定性，毕竟，突然产生大量的渲染对象会大量耗用 CPU 资源。所以，在实际应用中最好使用对象缓冲技术，即在使用粒子发生器前创建对象池，创建粒子的时候直接从缓冲池获取，粒子生命完结的时候把粒子放回到缓冲池。

6.6 小结

好了，关于游戏中常用的AI算法就介绍到这里，游戏中的算法当然远远不只介绍的这些，希望读者能在此基础上学习更多更有效的算法，因为，游戏的核心除了渲染，最主要的就是AI算法了。

第7章 Box2D引擎和物理游戏

2011年最火的移动平台游戏是什么？相信大部分玩家都玩过了，对，就是《愤怒的小鸟》。为了报复偷走鸟蛋的肥猪们，愤怒的红色小鸟以自己的身体为武器，像炮弹一样去攻击肥猪们的堡垒，砸死所有的肥猪。哦，这就是游戏的全部，挺简单有趣的游戏，男女老少乐此不疲，一个这样火的游戏，背后最大的功臣非Box2D莫属。

Box2D是什么？是一款免费的、优秀的基于2D的物理引擎，能让游戏表现出贴近现实的物理效果，Rovio 公司凭借这款优秀的物理引擎成功地开发出《愤怒的小鸟》这款奇作，获得了巨大的成功，赚取了大量的钞票。可惜的是他们居然对Box2D的贡献视为无物，惹得Box2D的作者在2011年GDC大会上真成了《愤怒的小鸟》，这充分说明了一个道理：用什么工具不重要，重要的是，你能用这个工具做出什么。

当然，好的工具会提高工作效率，这一章就让我们揭开Box2D这款优秀引擎的面纱。

7.1 Box2D基础

7.1.1 安装Box2D库

最初，Box2D是用AS语言编写，应用于Flash上的适用于刚体物理学的免费引擎，后移植到C++平台。由于《愤怒的小鸟》而名声大噪，鉴于其巨大的影响力，目前Box2D提供了多语言的支持，比如AS版本、C++版本、Java版本等，广范应用于Windows、iOS、Android等各大平台。为了更好地迎合HTML5的到来，Box2D同时也提供了JavaScript版本。

为了使用Box2D的Web版本，可以从https://code.google.com/p/box2dweb/下载Box2D的Web最新版本。目前的版本是Box2DWeb-2.1a.3版本，该版本是使用JavaScript语言编写，可直接使用于HTML5。由于几乎是直接从Flash版本移植过来的，对于了解Flash版本的读者来说，使用起来更为顺手。

关于Box2D的Web版本有两个，一个是Box2Djs，这个版本目前已经停止更新了，另一个版本是Box2DWeb，基本上是和Flash版本同步更新的，本章使用Box2DWeb版本。

下载Box2DWeb-2.1a.3，解压后有两个js文件，Box2DWeb-2.1.a.3.min.js是经过压缩之后的源代码文件，另一个是则是没压缩的文件，开发的时候，直接引用这个经过压缩的文件即可。

7.1.2 Box2D中的基本概念

在使用Box2D进行开发之前，有必要先了解以下几个在Box2D中使用的基本概念（这些概念源自Box2D的开发文档）。

• 形状（shape）：2D 几何对象，比如圆形（circle）或多边形（polygon），Box2D 的所有物体都可以分解成各个不同的形状。

• 刚体（rigid body）：在物理学中，刚体（rigid body）是一种有限尺寸，可以忽略形变的固体。不论是否感受到外力，在刚体内部，质点与质点之间的距离都不会改变。Box2D中使用物体（body）来表示刚体。

• 夹具（fixture）：fixture将形状绑定到物体之上，并有一定的材质属性，比如密度（density）、摩擦（friction）和恢复（restitution）。

• 约束（constraint）：约束是个物理连接，用于消除物体的自由度。在2D中，物体有3个自由度（水平、垂直、旋转）。如果我们把一个物体钉在墙上（像钟摆那样），那就把它约束到了墙上。这个时候，此物体就只能绕着钉子旋转，所以这个约束消除了它2个自由度。

• 接触约束（contact constraint）：一种特殊的约束，设计的目的是为了防止刚体被穿透，也用于模拟摩擦和恢复。接触约束不用你来创建，它们会自动被Box2D生成。

• 关节（joint）：关节就是种约束，用于将两个或多个body固定到一起。Box2D 支持不同的关节类型：转动（revolute）、棱柱（prismatic）、距离（distance）等。一些关节可以有限制（limits）和马达（motors）。

• 关节限制（joint limit）：关节限制限定了一个关节的运动范围。例如人类的胳膊肘只能在某一角度范围内运动。

• 关节马达（joint motor）：根据关节的自由度，关节马达可以驱动关节所连接的物体。例如，你可以使用一个马达来驱动一个肘的旋转。

• 世界（world）：一个物理世界就是由各种刚体（body）、夹具（fixture）、约束（constraint）相互作用的集合。Box2D支持创建多个世界，通常只需要一个。

Box2D包含3个主要的模块，分别是公共（Common）、碰撞（Collision）和动态（Dynamics）。

Common模块主要包括了内存分配、数学计算和配置。Collision模块定义了形状（shapes）、broad-phase 检测和碰撞功能/查询（collision functions/queries）。最后，Dynamics 模块提供对世界（world）、刚体（bodies）、夹具（fixtures）和关节（joint）的模拟。

在使用Box2Dweb的时候，基本上我们按照以下步骤开发即可。

（1）创建world，设置世界参数。

（2）定义形状和物体以及相关的物理参数。

（3）设置世界更新函数，让世界符合物理特性运动起来。

（4）绘制最终世界以及世界中的各种物体。

下面，我们就按照这样的基本步骤来看看如何使用Box2D中的API完成基本的应用。

7.1.3 创建Box2D物理世界

使用Box2D的第一个步骤，首先必须创建Box2D物理世界，创建Box2D世界比较简单，以下代码就创建出一个简单的物理空间：

var gravity = new Box2D.Common.Math.b2Vec2(0, 10);

var sleep = true;

world = new Box2D.Dynamics.b2World(gravity, sleep);

创建物理空间通常需要两个参数，一个是重力加速度向量gravity，使用Box2D的二维向量表示，上例中创建了一个向下的重力加速度，通常情况下 x 始终为 0，这也是符合现实客观规律的。另一个参数sleep表示的是：是否允许body在静止时休眠，休眠中的body不需要任何模拟，这种情况下会节省CPU资源。

创建物理空间就这样完成了，空间定好之后，接下来的工作就是往这个空间中放入物体。

7.1.4 添加物体到空间中

使用Box2D创建一个物体到物理空间中在通常情况下需要以下3个过程。

（1）定义物体：可以把物体看成是多个形状的组合体，形状由其定义相对位置（localPosition）决定其在物体中的位置，形状添加到物体后，其相对位置始终保持不变。

（2）定义材质：物体的材质决定了物体的物理属性，常用的材质有形状、摩擦力、弹性、密度等，形状是描述物体的外观，摩擦力、弹性、密度等这些属性主要用于物体运动的特性，材质定义之后，Box2D才能根据物理属性进行碰撞检测以及运算。

（3）创建物体：使用世界的 CreateBody()来创建一个已经定义好的物体，物体是物体定义的实例。只有使用这个函数生成的物体，才会在世界中被模拟。

现在，我们完成一个简单的例子，物理世界中有一个地板，然后随机创建几个圆球在地板上滚动。

首先，来创建一个地板。这里我们认为地板是固定不动的长方体。

按照步骤，首先进行物体定义，定义一个地板物体，代码如下：

//定义floor类型

var fBodyDef = new Box2D.Dynamics.b2BodyDef ();

fBodyDef.type = Box2D.Dynamics.b2Body.b2_staticBody;

fBodyDef.position.Set(100，100);

通过 Box2D.Dynamics.b2BodyDef 类进行一个物体定义，需要注意的是，这一步是用来定义物体类型，并不是定义物体，物体是这个物体类型的具体化，用OO的说法就是相当于类和对象的关系一样。创建好了类型定义后，可以给该物体设置type，通常有三种type，b2_staticBody表示静态物体，b2_dynimacBod表示动态物体，b2_kinematicBody表示运动学物体。在游戏中，静态物体被用来创建墙、地板等静止不动的物体，动态物体通常被用来创建玩家和其他场景中运动的角色等，而运动学物体比较特殊，它是可以运动，但不会受到其他动态物体影响的物体，比如《超级玛丽》中左右移动的跳台，玛丽可以通过这个跳台跳到后面的场景中。

设置完类型后，通过position.Set(x, y)方法设置物体在整个世界中的位置，需要注意的是，这个位置是以物体的质心为准。Box2D中world并不保留对物体类型定义的引用，前面的10个铁球可以使用一个物体类型定义，只需要设置不同的位置即可。

定义好了物体类型后，接着定义物体材质，材质包含物体的形状、摩擦力、弹性、密度等，定义好了材质之后，可以把材质附加给物体。这里由于使用了静态物体，所以可以使用默认的材质，只需要定义形状即可，下面看看如何定义形状，代码如下：

//创建形状

var box = new Box2D.Collision.Shapes.b2PolygonShape ();

box.SetAsBox(300, 5);

//设置用户数据

bodyDef.userData ={w:200/30, h:5/30};

这里使用Box2D.Collision.Shapes.b2PolygonShape了一个凸多边形，SetAsBox使得多边形构成一个盒子形状，盒子的中心点就是物体的质心，SetAsBox函数接收半个宽度和半个高度作为参数。因此在这种情况下，地面盒就是600个单位宽（x轴）、10个单位高（y轴），然后可以通过bodyDef的userData属性可以给bodyDef设置任意的附加属性数据，这些属性数据可用于游戏中的一些其他逻辑。

这里，需要了解一下 Box2D 中使用的常见单位，Box2D 中使用米和千克为标准的计量单位，这个也符合国际标准。也就是说，正常情况下，canvas中的1像素在Box2D中表示1米，但在实际开发中，由于直接使用这个比例会让Box2D的效率降低，而且1像素的差距在canvas中感觉不明显，但在现实生活中差别很大，所以一般我们会修改这个比例。按照惯例，通常设置30像素为1米，假设设置这个比例pixelPerMeter=30，则在Box2D中的实际大小为：Box2DMeter=pixel/pixelPerMeter，反之，换算成canvas 中的像素应该是：pixels = pixelPerMeter×Box2DMeter，所以实际上，上面我们定义的地板的长度在Box2D中是600/30=20米。

最后，我们终于可以创建物体了，代码如下：

//创建floor;

floor = world.CreateBody(fBodyDef);

//创建材质

floor.CreateFixture2(box);

这里创建物体有两个步骤，首先通过world的CreateBody(bodyDef)方法创建一个由bodyDef定义的物体实例，然后通过物体实例的CreateFixture2(box)方式，把材质赋予物体对象，由于这里使用了默认的材质，所以可以直接把形状赋给物体，在接下来的创建小球的过程中，可以清楚地看到如何创建一个材质。

好了，地板已经创建成功，接下来，创建一个小球，创建小球的过程和地板一样，先创建小球类型，然后定义小球材质，最后创建小球的实例，代码如下：

//定义ball类型

var bodyDef = new Box2D.Dynamics.b2BodyDef();

bodyDef.type = Box2D.Dynamics.b2Body.b2_dynamicBody;

//创建形状

var ba = new Box2D.Collision.Shapes.b2CircleShape(20);

//创建材质

var fix = new Box2D.Dynamics.b2FixtureDef();

fix.shape = ba;

fix.density = 0.6;

fix.friction = 0.3;

fix.restitution = 0.5;

bodyDef.position.Set(100, 100);

//创建小球

var ball = world.CreateBody(bodyDef);

ball.CreateFixture(fix);

可以看到，这个创建小球的方式和之前类似，首先创建小球类型，由于小球是运动物体，此时把小球定义的type设置成b2_dynamicBody，然后创建形状，此时，使用定义好的b2CircleShape()类，该类用于创建一个圆形，需要用半径做参数。

接下来，使用Box2D.Dynamics.b2FixtureDef创建一个材质，该材质最终会附加到一个物体上，该物体就具有材质的物理属性了，常见的属性有shape，表示形状，density表示密度，值使用kg/m^2表示，即千克/平方米，friction表示摩擦力，范围是[0，1]，restitution表示恢复系数，该系数是反映碰撞时物体变形恢复能力的参数，恢复系数为1表示碰撞为弹性碰撞，如果恢复系数<1，则表示为非弹性碰撞，如果恢复系数为0则表示完全非弹性碰撞，两个物体基本上是粘贴在一起，没有任何弹跳运动。定义好了材质、物体类型后，就可以通过world的CreateBody()方法创建小球了。

到此为止，所有的初始化工作已经全部完成，接下来，我们需要把所有的物体展现出来。

7.1.5 渲染Box2D世界

物理空间建立好之后，可以使用Box2D内置的debug Draw类呈现我们创建的Box2D世界，使用debugDraw的基本步骤如下：

var b2DebugDraw = Box2D.Dynamics.b2DebugDraw;

//创建debugDraw实例

var debugDraw = new b2DebugDraw();

//设置物理空间显示在那个canvas中

debugDraw.SetSprite(canvas.getContext('2d'));

//设置显示规模，这个参数表示的意义是像素和box2d中meter的比例

debugDraw.SetDrawScale(30.0);

//设置填充的alpha值

debugDraw.SetFillAlpha(0);

//设置画线的alpha值

debugDraw.SetAlpha(0);

//设置线的宽度

debugDraw.SetLineThickness(1.0);

//设置一些绘制的参数

debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);

//最后，告诉世界用这个b2DebugDraw 实例来绘制

world.SetDebugDraw(debugDraw);

好了，以上这些代码创建了一个debugDraw的类，该类会设置一些用于显示的参数，然后world会通过该类进行显示，最终会把物理世界描绘在一个canvas中，这个canvas就是通过debugDraw.SetSprite()所指定的canvas。

需要注意的是，这个只是相当于告诉 Box2D 世界，需要把这个世界的物体渲染在一个canvas 中，实际上并没有做任何的渲染操作，所以，具体的绘制代码，还需要我们来处理。基本上，还是需要通过一个定时器来随时捕捉到Box2D的物理世界，然后使用canvas进行绘制，即要通过以下代码更新Box2D中的物理世界：

window.setInterval("update()", 1000/60);

表示每1000/60毫秒进行一次更新渲染，那么update方法中应该做些什么呢？大体上需要以下两个主要的步骤。

（1）更新Box2D中的物理空间。

（2）获取Box2D物理空间中需要绘制的物体，进行绘制。

其中第一个步骤所要做的工作就是计算在一段时间内，Box2D物理空间中的所有物体的变化，比如位置、碰撞、变形等，最终得到每个物体的物理数据。需要注意的是，Box2D只是物理引擎，它只负责跟踪计算出物理空间中物体随时间变化后的各种物理数据，有了这些数据后，剩下的工作就交给渲染引擎根据这些数据绘制物体。第二个步骤就是得到所有物体的实时数据，然后渲染。

根据这个步骤，得到以下代码。其中update的方法如下：

//更新

function update()

{

//更新物理世界

world.Step(1/60, 8, 1);

world.DrawDebugData();

world.ClearForces();

//绘制物体

ctx.strokeStyle = "yellow";

ctx.lineWidth = 4;

for (var b = world.GetBodyList() ; b; b = b.GetNext())

{

//获取物体当前位置

var pos = b.GetPosition();

//获取物体附加数据

var uData = b.GetUserData();

var ang = b.GetAngle();

if(b.GetType() == b2Body.b2_dynamicBody &&uData!=null)

{

ctx.save();

ctx.beginPath();

ctx.translate(pos.x*30, pos.y*30);

ctx.rotate(ang);

ctx.moveTo(0, 0);

ctx.arc(0, 0, uData*30, 0, Math.PI*2);

ctx.stroke();

ctx.restore();

}

//绘制地板

if(b.GetType() == b2Body.b2_staticBody &&uData!=null)

{

ctx.save();

ctx.beginPath();

ctx.strokeRect((pos.x-uData.w)*30, (pos.y-uData.h)*30, uData.w*60, uData.h*60);

ctx.restore();

}

}

}

这里，首先需要使用下面几行代码更新物理世界：

world.Step(1/60, 8, 1);

world.DrawDebugData();

world.ClearForces();

首先调用world.Step()方法，该方法会在后台模拟一定时间内整个物理世界的变化，得到所有物体在物理空间中最新的数据。该方法有3个参数，第一个表示需要模拟的时间段，以秒为单位，第二个参数是速度迭代次数，推荐 8 次，第三个参数是位置迭代，1 次就可以了，后面两个参数越大模拟得越精确，但会降低运行效率。接着调用world.DrawDebugdata()绘制，实际上，这个方法只是把canvas清空，为具体的渲染做准备，最后调用world.ClearForces()方法取消所有作用在物体上的力，防止该力一直作用于物体。

好了，模拟通过上面3个步骤我们可以得到Box2D空间中所有物体实时的数据了，此时，就可以根据这些数据绘制，绘制的时候可以通过world.GetBodyList()得到Box2D中的所有物体列表，然后迭代这个列表根据需要绘制物体，在绘制之前可以通过body的一系列的Get开头的方法获取物体当前的物理数据，Get开头的一系列方法可以参考Box2D的API手册。

关于Box2D的基础的部分已经差不多了，利用所学的知识可以做一些简单的应用了，图7-1展示了采用以上步骤制作的小球落在地板上的例子。

图7-1 随机掉落的小球

接下来，我们看看，如何把Box2D应用到之前的渲染引擎中去。

7.2 案例：结合渲染引擎渲染Box2D世界

在第 4 章中，我们创建了一个 xengine 的渲染引擎，这一小节中，来看看如何把 xengine和 Box2D 结合起来。如图 7-2 所示，利用 xengine和Box2D创建了一个简单的例子，在该例子中，用户可以用鼠标点击游戏窗口中任意位置，在该位置中将产生一只精灵。

图7-2 精灵的世界

下面，我们将一步步结合 xengine 和 Box2D，任何时刻我们需要注意的是，Box2D只是物理引擎，我们只需要通过它来得到物理空间中物体的数据，然后把这些数据交给渲染引擎去做就可以了。

7.2.1 建立游戏框架，初始化Box2D世界

首先，和以前一样，先来建立游戏框架，从xengine中的Game继承：

(function(win){

var _Game = Game.extend({

//Box2D世界

b2World:null,

//Box2D比例

b2Scale:30,

frames:60,

init:function()

{

this._super();

var scm = this.sceneManager;

//创建场景

var x = (document.body.clientWidth-1024)*0.5;

var sc = scm.createScene([{"x":x, "w":1024, "h":600, "name":"main"}]);

//设置场景背景

sc.setBGImg("imgs/bg.png", 1);

this.loadRes(sc);

},

//加载资源

loadRes:function(scene)

{

var self=this;

ResManager.loadRes("data/res.json", function(){

self.initSprite();

self.run(self.frames);

});

},

//创建精灵

initSprite:function()

{

}

})

这个框架基本和以前的类似，初始化场景，加载游戏资源，然后运行游戏。

接下来，我们需要初始化Box2D世界，这个工作只需要做一次操作，所以也可以放在init方法中，当然，最好我们写一个初始化Box2D的函数，在以上的Game类中增加了以下2个函数，用于初始化Box2D世界：

//初始Box2D

initBox2d:function()

{

b2Vec2=Box2D.Common.Math.b2Vec2;

b2AABB = Box2D.Collision.b2AABB;

b2BodyDef = Box2D.Dynamics.b2BodyDef;

b2Body = Box2D.Dynamics.b2Body;

b2FixtureDef = Box2D.Dynamics.b2FixtureDef;

b2Fixture = Box2D.Dynamics.b2Fixture;

b2World = Box2D.Dynamics.b2World;

b2MassData = Box2D.Collision.Shapes.b2MassData;

b2PolygonShape = Box2D.Collision.Shapes.b2PolygonShape;

b2CircleShape = Box2D.Collision.Shapes.b2CircleShape;

b2DebugDraw = Box2D.Dynamics.b2DebugDraw;

b2MouseJointDef = Box2D.Dynamics.Joints.b2MouseJointDef;

//初始Box2D世界

this.b2World = new b2World(new b2Vec2(0, 60), true);

this.initDebugDraw();

},

//初始化box2dDebugDraw

initDebugDraw:function()//绘制

{

var sc=this.sceneManager.getScene("main");

var debugDraw = new b2DebugDraw();

debugDraw.SetSprite(sc.ctx);

debugDraw.SetDrawScale(this.b2Scale);

debugDraw.SetFillAlpha(0);

debugDraw.SetAlpha(0);

debugDraw.SetLineThickness(1.0);

debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);

this.b2World.SetDebugDraw(debugDraw);

}

其中，initBox2d方法用于初始化Box2D世界，这个方法中还定义了一些全局变量，主要是为了简化访问Box2D的各个类，initDebugDraw方法和前面一样，用于设置Box2D渲染到canvas中。

初始化的工作做好了，接下来，我们需要做的是更新Box2D物理世界。

7.2.2 更新Box2D物理世界

按照前面所学的知识，在渲染所有的精灵之前必须要使用 Box2D 物理引擎先更新整个物理世界，然后根据物理世界中物体的新的数据进行渲染，所以更新物理世界的方法最适合的位置就是放在渲染工作之前，因此，我们给Game类中添加一个beforeRender监听器，代码如下：

//增加游戏监听器

initListener:function()

{

var self=this;

//增加监听器

var ltn = new AppEventListener({

"beforeRender":function(){

self.b2World.Step(1/self.frames, 8, 1);

self.b2World.DrawDebugData();

}

});

this.addListener(ltn);

}

可以看到，我们给Game添加了一个beforeRender监听器，然后在监听器中增加了Box2D物理世界的更新，剩下的工作，就是交给渲染引擎渲染精灵了。

7.2.3 创建Box2D物理精灵

这里，我们创建一个Pig的精灵类，当然需要继承至Sprite类，还记得之前，我们如何创建Box2D中的物体吗？对了，先定义物体类型，然后创建物体，给物体附加材质。这个过程都比较熟悉了，但在这里需要注意的是，我们渲染的精灵并不是Box2D中定义的物体，而是只需要户获取Box2D中的物理数据，确切地说，只需要Box2D中物体的位置以及旋转角度，有了物体的位置和旋转角度就可以进行渲染了，所以，在精灵的属性中必须增加一个属性用于和Box2D中对应的物体进行关联，于是得到了以下的Pig类代码：

(function(){

//从引擎的Sprite继承

Pig = Sprite.extend({

init:function(r)

{

//对应Box2D中的物体引用

this.b2Obj = null;

this._super();

},

//创建Box2D中的物体

createInB2:function()

{

//定义为圆形

var bodyDef = new b2BodyDef();

bodyDef.type = b2Body.b2_dynamicBody;

//创建形状

var ba = new b2CircleShape(TGame.lenToB2d(this.w*0.5));

//创建材质

var fix = new b2FixtureDef();

fix.shape = ba;

fix.density =10;

fix.friction = 0.9;

fix.restitution = 0.1;

//创建物体

bodyDef.position.Set(TGame.lenToB2d(this.x), TGame.lenToB2d(this.y));

var pig = TGame.b2World.CreateBody(bodyDef);

Pig.CreateFixture(fix);

//绑定Box2D中物体

this.b2Obj = Pig;

//绑定pig

Pig.sprite = this;

},

update:function()

{

if(this.b2Obj!=null)

{

//获取物体当前位置

var pos = this.b2Obj.GetPosition();

this.x = TGame.lenToScn(pos.x);

this.y = TGame.lenToScn(pos.y);

//获取物体角度

var ang = this.b2Obj.GetAngle();

this.deg = MathUtil.rad2deg(ang);

}

}

})

//注册Pig到类工厂

Pig.ClassName = "Pig";

ClassFactory.regClass(Pig.ClassName, Pig);

}())

在Pig类中，我们添加了一个b2Obj属性，该属性用于连接到Box2D世界中的物体，然后，我们重写了 Sprite 中的 update 方法。在 update 方法中，我们通过 b2Obj 可以获取和该对象在Box2D世界中关联的物体，然后经过了Box2D世界的更新方法后，就可以获取该对象当前准确的位置信息以及旋转角度，然后就可以进行正确的渲染了。在 Pig 类中提供了一个 createInB2方法，用于创建和Pig对象相关联的Box2D中的物体，该方法在创建Pig对象后调用，相信前面Box2D基础部分掌握的话，这个函数应该很简单，需要注意的是下面两行代码：

//绑定Box2D中物体

this.b2Obj = Pig;

//绑定Pig

Pig.SetUserData(this);

这两行的代码是用于建立Pig对象和Box2D中body的双向关联，这样，就可以在两者之间互相访问对方。

好了，一切都准备就绪了，最终，我们的Game类的完整代码如下：

(function(win){

var _Game = Game.extend({

//Box2D世界

b2World:null,

//Box2D比例

b2Scale:30,

frames:60,

init:function()

{

this._super();

var scm = this.sceneManager;

//创建场景

var x = (document.body.clientWidth-1024)*0.5;

var sc = scm.createScene([{"x":x, "w":1024, "h":600, "name":"main"}]);

//设置场景背景

sc.setBGImg("imgs/bg.png", 1);

//初始化Box2D

this.initBox2d();

//初始化监听器

this.initListener();

this.loadRes(sc);

},

//初始Box2D

initBox2d:function()

{

b2Vec2=Box2D.Common.Math.b2Vec2;

b2AABB = Box2D.Collision.b2AABB;

b2BodyDef = Box2D.Dynamics.b2BodyDef;

b2Body = Box2D.Dynamics.b2Body;

b2FixtureDef = Box2D.Dynamics.b2FixtureDef;

b2Fixture = Box2D.Dynamics.b2Fixture;

b2World = Box2D.Dynamics.b2World;

b2MassData = Box2D.Collision.Shapes.b2MassData;

b2PolygonShape = Box2D.Collision.Shapes.b2PolygonShape;

b2CircleShape = Box2D.Collision.Shapes.b2CircleShape;

b2DebugDraw = Box2D.Dynamics.b2DebugDraw;

b2MouseJointDef = Box2D.Dynamics.Joints.b2MouseJointDef;

//初始Box2D世界

this.b2World = new b2World(new b2Vec2(0, 60), true);

this.initDebugDraw();

},

//初始化box2dDebugDraw

initDebugDraw:function()//绘制

{

var sc=this.sceneManager.getScene("main");

var debugDraw = new b2DebugDraw();

debugDraw.SetSprite(sc.ctx);

debugDraw.SetDrawScale(this.b2Scale);

debugDraw.SetFillAlpha(0);

debugDraw.SetAlpha(0);

debugDraw.SetLineThickness(1.0);

debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);

this.b2World.SetDebugDraw(debugDraw);

},

//加载资源

loadRes:function(scene)

{

var self=this;

ResManager.loadRes("data/res.json", function(){

self.initSprite();

self.run(self.frames);

});

},

//转换长度到Box2D世界

lenToB2d:function(pixel)

{

return pixel/this.b2Scale;

},

//转换Box2D长度到屏幕像素

lenToScn:function(b2Len)

{

return this.b2Scale*b2Len;

}

,

//创建精灵

initSprite:function()

{

this.initFloor();

},

//创建小猪

createPig:function(x, y)

{

var sc=this.sceneManager.getScene("main");

var pig = sc.createRObj(Pig.ClassName);

pig.setAnims(ResManager.getAnimationsByName("sprite", "pig"));

pig.setAnimSpeed(0.1);

pig.moveTo(x, y);

pig.createInB2();

},

//创建地板，地板不需要绘制

initFloor:function()

{

//定义floor类型

var bodyDef = new b2BodyDef();

bodyDef.type = b2Body.b2_staticBody;

bodyDef.position.Set(this.lenToB2d(512), this.lenToB2d(530));

//创建形状

var box = new b2PolygonShape();

box.SetAsBox(this.lenToB2d(512), this.lenToB2d(10));

//创建floor;

var floor = this.b2World.CreateBody(bodyDef);

//创建材质

floor.CreateFixture2(box);

},

//增加游戏监听器

initListener:function()

{

var self=this;

//增加监听器

var ltn = new AppEventListener({

"beforeRender":function(){

self.b2World.Step(1/self.frames, 8, 1);

self.b2World.DrawDebugData();

}

});

this.addListener(ltn);

}

})

//定义全局TGame

win.TGame = new _Game();

//定义鼠标事件

Mouse.sDLG("down", function(e){

//获取游戏窗口坐标

var sc = TGame.sceneManager.getScene("main");

var gx = sc.x,

gy = sc.y,

mx = Mouse.gX(),

my = Mouse.gY();

//转换鼠标坐标到游戏窗口坐标系

var cd = MathUtil.mapSToCoord(mx, my, gx, gy);

var pig = TGame.createPig(cd[0], cd[1]);

});

}(window))

这样，就完成了渲染引擎和物理引擎的整合，后面，我们将会利用这些引擎来完成《愤怒的小鸟》第一关的制作，给自己一点鼓励，相信自己能够做出更优秀的游戏。

7.3 小结

好了，关于强大的物理引擎 Box2D 部分就介绍到这里，当然，这里所介绍的只是 Box2D引擎的冰山一角，但基本可以利用现有的功能完成一些游戏制作，比如像《愤怒的小鸟》，就目前的所学足够做一个HTML5版本的。随时记住一点，游戏的内涵就是好玩，创意才是根本。

第8章 使用CSS3开发游戏

在使用HTML5进行游戏开发的过程中，使用canvas是一种选择，随着HTML5的发展和CSS3提供了更多、更有效的功能，完全使用CSS开发游戏也是一种选择，特别是在目前canvas元素受到一定性能的制约，更多的时候需要使用两种技术的协作来完成。本章主要介绍有关于CSS3相关的一些知识，对于熟悉CSS3的读者可以略过此章。

8.1 CSS历史

从1990年HTML的发明开始，样式就伴随着HTML诞生，样式表赋予网页以各种丰富多彩的形式。把内容和形式进行分离有利于设计开发人员的更有利的分工，获得优势最大化，同时，也更方便后期的维护和修改，所以，样式相对于HTML就相当于衣服相对于人，人要衣装，网页也不例外。

CSS的发展大致经历了以下几个关键的阶段。

（1）CSS1：W3C于1996年12月组织发布了CSS1的标准，该版本中主要包含文字的大小、形状，字体的颜色背景，文章的排版相关样式，文字的排列，图像以及其他排版样式，开始支持id和class样式。

（2）CSS2～2.1：W3C于1998年12月组织发布了CSS2的标准。该版本中主要增加了元素的相对、绝对、固定定位方式，一些新的伪类比如:first-child、:hover、:focus等，新增加了一些颜色和字体。

（3）CSS3：CSS3于1999年开始制订，到目前为止还处在一个发展的阶段。

这一章的内容不会完全的介绍 CSS的内容，那将是另一个庞大的话题，所以，本章主要介绍 CSS3 相对于之前的版本所增加的新的内容，首先我们来看看 CSS3 新增加的选择器。

8.2 CSS3选择器

CSS3中新增了多种样式选择器，这些样式选择器的灵活应用将大大提高开发人员的效率和增加样式表的可读以及可维护性，这里只会介绍相对于CSS1和CSS2的部分新增加的选择器类型。

8.2.1 属性选择器

CSS3中新增加了以下三种类型的子字符串匹配属性选择器。

（1）E[foo^="bar"]：该属性选择器描述的是选取属性以bar开头的元素，比如：

div[name^="g_"]{

color:red

}

以上代码描述的是所有以名称g_开头的div的字体颜色为红色。

（2）E[foo$="bar"]：该属性选择器描述的是选取属性以bar结尾的元素，比如：

div[name$="_g"]{

border:2px;

}

以上代码描述的是所有以名称_g结尾的div的边框宽度为2px。

（3）E[foo*="bar"]：选取属性中含有val值的元素，比如：

div[name*="red"]{

color:red

}

以上代码描述的是所有div中名称包含red的字体颜色为红色。

如果需要满足多个属性的，可以把属性选择并列书写，比如：

div[name^="g_"][id$="x"]{

color:red

}

以上代码描述的是所有以名称g_开头并且id以x结尾的div的字体颜色为红色，注意，多个属性选择器并列的时候是and关系。

需要注意的是，该属性选择器的属性支持元素的本身属性以及用户通过 data-*进行自定义的属性。

8.2.2 结构伪类选择器

CSS3中新增加了以下12种新的结构伪类选择器。

（1）E:root。root选择器指定文档的根元素使用的样式，在html中所指的就是<html>元素，所以，可以通过root样式指定整个网页的样式。

以下代码将把整个网页的背景设置成红色：

:root{

background:red

}

（2）E:nth-child(a*n+b|(odd)|(even))。指定元素的第a*n+b个子元素的附加样式，a和b的值均可为正数、负数或0，也可以使用odd或者even字符串，这个伪类有点复杂，我们先从简单的开始。

最简单的情况是：nth-child(n)的形式，n是常数，表示的是选取第n个子元素，比如：

<!DOCTYPE html>

<meta charset="utf-8">

<style type="text/css">

div:nth-child(2){

color:red

}

#d2 div:nth-child(3){

color:blue

}

</style>

<html>

<body>

<div id="d1">

<div>第一项</div>

<div>第二项</div>

<div>第三项</div>

<div>第四项</div>

<div>第五项</div>

</div>

<div id="d2">

<div>第一项</div>

<div>第二项</div>

<div>第三项</div>

<div>第四项</div>

<div>第五项</div>

</div>

</body>

</html>

这里，为了能更好地演示效果，特别使用了2组div作为容器，设计了2个样式：

div:nth-child(2){

color:red

}

第一个样式表示第2个div子元素节点字体为红色。需要注意的是，所谓的第2个子元素指的是在整个文档的dom树结构中，只要某个div是作为任意一个元素的第2级子元素，那么该div就被选取。这种情况下，id="d1"的第1个div容器下的第2项被选中，显示红色，同时， id="d2"的第2个div整个容器作为body的第2个子元素，所以它也将被选中，其所有的子元素字体都将显示红色。

#d2 div:nth-child(3){

color:blue

}

以上这个样式就直接指定了编号为d2下面的第3个div子元素的字体颜色为蓝色。

接下来看看稍复杂一些的形式，比如：nth-child(a*n+b)，其中a>0，b>0，这种情况下，将会对子元素按照每a个一组进行分组，如果b>0表示分组后的第b个子元素将会被选中，如果b小于0则表示分组后倒数第b个子元素被选中，比如，我们做一个表格交叉显示浅黄色和红色：

<!DOCTYPE html>

<meta charset="utf-8">

<style type="text/css">

tr:nth-child(2n){

background-color:yellow

}

tr:nth-child(2n+1){

background-color:red

}

</style>

<html>

<body>

<table width="60%" border="1">

<tr>

<td>1</td>

<td>2</td>

<td>3</td>

</tr>

<tr>

<td>1</td>

<td>2</td>

<td>3</td>

</tr>

<tr>

<td>1</td>

<td>2</td>

<td>3</td>

</tr>

<tr>

<td>1</td>

<td>2</td>

<td>3</td>

</tr>

</table>

</body>

</html>

本例定义了两个样式，一个是：

tr:nth-child(2n){

background-color:yellow

}

它表示所有为2的倍数的tr背景颜色为黄色，其实就是表示偶数行，当a=2时，这种样式的等效效果是使用even，以上样式等价于：

tr:nth-child(even){

background-color:yellow

}

另一个样式是：

tr:nth-child(2n+1){

background-color:red

}

它表示的意义是，把tr进行每2个分组，分组后的第1个元素的背景为红色，其实就是表示奇数行，当a=2时，这种样式的等效效果是使用odd，以上样式等价于：

tr:nth-child(odd){

background-color:red

}

以上描述的是当a=2的情况，当a>2的时候，所描述的含义一样，只是分组的大小不同而已，比如以下描述：

nth-child(4n+1)：匹配第1、5、9……4n+1，即每4个1组的第1个。

nth-child(4n−1)：匹配第3、7、1……4n−1，即每4个1组的倒数第1个。

如果a<0，则要求a*n+b的值大于0，比如：

nth-child(−2n+3)：匹配第3、1项。

当需要取前n项的时候，这就比较有用了，比如需要获取前面5项，可以通过以下设置：

nth-child(−n+5)：匹配前5项

（3）E:nth-last-child(a*n+b|(odd)|(even))。该用法和使用:nth-child 样式一样，只不过在选取元素的时候是从后面往前面进行计算，所以，这里就不再说明。

（4）E:nth-of-type(a*n+b|(odd)|(even))。参数的含义和前面使用 nth-child 一样，它描述的是匹配同类型的第n个兄弟元素，我们先来看一个例子：

<!DOCTYPE html>

<meta charset="utf-8">

<style type="text/css">

div:nth-child(2){

color:red

}

</style>

<html>

<body>

<div id="d1">

<div>第一项</div>

<a href="#">div 的第二个子元素</a>

<div>第二项</div>

<div>第三项</div>

</div>

</body>

</html>

按照我们的构想，我们希望id="d1"的div的第2个div的颜色是红色。事实上，这个样式是失败的，因为它描述的意义是，当某个div作为其父节点的第2个元素是被选择中，此时，我们真找不到这样的一个div，因为id="d1"的div的第2个div它是其父节点的第3个子元素，所以该div被忽略，碰到这种情况，我们可以使用:nth-of-type：

div:nth-of-type(2){

color:red

}

它描述的是选择父元素的第2个div元素，也就是说，它首先会把相同类型的子元素集合，然后再进行选择。

（5）E:nth-last-of-type(a*n+b|(odd)|(even))。该选择器的用法同nth-last-child类似，其区别也就是nth-child和nth-of-type的区别。

（6）E:first-child。选择元素的第1个子元素，等同于nth-child(1)。

（7）E:last-child。选择元素的最后1个子元素，等同于nth-last-child(1)。

（8）E:first-of-type。选择同该元素相同类型的第1个兄弟元素，等同于nth-of-type(1)。

（9）E:last-of-type。选择同该元素相同类型的最后1个兄弟元素，等同于nth-last-of-type(1)。

（10）E:only-child。选择一个元素，该元素的父元素只含有该元素，换句话说，就是选择没有兄弟节点的元素，这个选择器很容易误导为选择只有一个子元素的元素，它等同于:first-child:last-child 或者:nth-child(1):nth-last-child(1)，这里需要注意的是，选择的不是该元素的父元素，来看看以下代码：

<!DOCTYPE html>

<meta charset="utf-8">

<style type="text/css">

div:only-child{

color:red;

}

</style>

<html>

<body>

<div id="d1">

<div id="d3">hello</div>

</div>

<div id="d2">test</div>

</body>

</html>

事实上，通过该选择器选中的是id="d3"的div元素，不会选取其他的div元素，因为该样式描述的意义是选择一个没有兄弟节点的div元素，只有id="d3"的元素满足。

（11）E:only-of-type。选择没有同类型兄弟节点的元素，和E:only-child类似，只是要求类型相同，比如，对于以下样式：

<style type="text/css">

div:only-of-type{

color:red;

}

</style>

如果有以下元素：

<html>

<body>

<div id="d1">

<div id="d3">hello</div>

</div>

<div>

hello

</div>

</body>

</html>

此时，只有id="d3"的div会被选中，而其他div无法被选中，因为其他div有同类型的兄弟节点元素，假设元素改成以下：

<html>

<body>

<div id="d1">ss

<div id="d3">hello</div>

</div>

<span>

hello

</span>

</body>

</html>

此时，id="d1"和id="d3"的div都将被选中。

（12）E:empty。选择没有任何子节点的元素，子节点包括文本节点，比如，以下代码：

<!DOCTYPE html>

<meta charset="utf-8">

<style type="text/css">

div:empty{

background:blue;

}

</style>

<html>

<body>

<div id="d1">

<div id="d3" style="width:100px;height:30px"></div>

<div id="d4" >hello</div>

</div>

</body>

</html>

此时，id="d3"的div将被选中，而id="d4"的不会被选中，因为该div包含一个hello文本节点。

8.2.3 target伪类选择器

target选择器是一个目标选择器，用法如下。

E:target。表示的意义是，E元素的id作为一个URL的超链接使用，也就是说当该E元素作为一个锚点使用的时候，该元素被选中，并且，只有当用户选择超链接后才能生效。

如：

<!DOCTYPE html>

<meta charset="utf-8">

<style type="text/css">

section:target header{

color:red;

}

</style>

<html>

<body>

<a href="#sc1">demo1</a>

<a href="#sc2">demo2</a>

<section id="sc1">

<header>Test1</header>

<article>

This is a test1;

</article>

</section>

<section id="sc2">

<header>Test2</header>

<article>

This is a test2;

</article>

</section>

</body>

</html>

该样式描述的意义是，被用户选择的section片段的header部分文字将会以红色字体显示。

8.2.4 UI 元素状态伪类选择器

CSS3 中新增加了几种关于元素状态的伪类选择器，这类选择器描述的是，当元素处于某种状态的时候，该元素将会被选中，新增加的元素状态伪类选择器有以下几个。

（1）E:enabled 和 E:disabled。E:enabled 表示当元素处于可用状态的时候，元素被选择， E:disabled表示当元素处于不可用状态的时候，元素被选中。

大部分html元素都具有disabled属性，一旦设置元素为disabled后，该元素表示不可以用，并且该元素的数据是无法提交到后台的。E:disabled选择器可以选择所有具有disabled属性的元素，反之，没有设置disabled属性的会被E:enabled选中。需要注意的是，元素的display属性和readonly属性不会影响该元素的disabled和enabled状态。

（2）E:checked。该选择器表示当元素处于被选中状态时被选择，这个属性只针对radio和checkbox元素起作用，在Firefox浏览器中需要加上前缀"-moz-checked"的形式。

8.2.5 反向伪类选择器

not选择器是一个目标选择器，用法如下。

E:not(X)。表示的意义是排除掉参数X选择器的所有元素，需要注意的是X选择器是一个简单的选择器类型，并且not选择器不能嵌套使用。

比如：

<style type="text/css">

input[type="button"]:not(:disabled){

color:red;

}

</style>

该代码描述的意义是，所有非disabled属性的按钮颜色为红色，等同于以下效果：

<style type="text/css">

input[type="button"]:enabled{

color:red;

}

</style>

8.2.6 通用兄弟元素选择器

用法如下。

E～F：该选择器描述的意义是选择E元素之后的所有兄弟节点元素，如：

<!DOCTYPE html>

<meta charset="utf-8">

<style type="text/css">

#dv1 ~ div{

color:red;

}

</style>

<html>

<body>

<div id="dv1">测试一</div>

<div>测试二</div>

<div>测试三</div>

</body>

</html>

此时，作为id="dv1"的其余两个兄弟节点div的字体都将变成红色。

关于CSS3中新增加的选择器就介绍到这里，使用新增加的选择器将会让开发人员更加灵活地操纵DOM元素样式，接下来来看看在CSS3中新增加的动画功能。

8.3 CSS3动画

在游戏应用方面，除了使用canvas来实现动画之外，CSS3提供了强大的动画处理功能，使得没有canvas的情况下，依然可以实现许多动画特效，大体上CSS的动画可分为变形、过渡、动画几个部分。接下来，我们看看CSS3的动画处理能力。

8.3.1 变形

CSS3中使用transform功能来实现元素在2维或者3维空间中的旋转、缩放、倾斜和平移，需要注意的是针对不用浏览器需要加上私有前缀，常见的几种浏览器内核的前缀如下：

Firefox：-moz

Safari：-webkit

Chrome：-webkit

Opera：-o

IE：-ms

使用变形效果，需要使用transform函数实现，这些变形效果针对所有的HTML元素。事实上，它是在进行DOM元素渲染过程的时候执行的变换，transform所支持的常见的变形效果如下。

（1）缩放。缩放用于对HTML元素的大小进行改变，表8-1显示了关于缩放操作的相关函数。

表8-1 缩放操作函数

比如，以下代码中，使用ul展示了一个有3个选项的菜单，当鼠标移动到菜单选项上时，该选项将放大1.2倍：

<!DOCTYPE html>

<meta charset="utf-8">

<style type="text/css">

ul{margin:0px;padding:0px;}

#menu{

list-style:none;

width:auto;

}

#menu li{

float:left;

border:1px solid gray;

background-color:#bfcbd6;

padding:10px;

}

#menu li:hover{

transform:scale(1.2);

-webkit-transform:scale(1.2);

}

</style>

<html>

<body>

<ul id="menu">

<li>第一项</li>

<li>第二项</li>

<li>第三项</li>

</ul>

</body>

</html>

（2）平移。平移用于对HTML元素的位置进行改变，表8-2显示了关于平移操作的相关函数。

表8-2 平移操作函数

同上例一样，我们把样式稍微修改一下，把上例缩放的样式修改成以下方式：

#menu li:hover{

transform:translateY(-10px);

-webkit-transform:translateY(-10px);

}

此时的效果变成当鼠标移动到li上面时，该菜单向上移动10个像素。

（3）旋转。旋转用于对HTML元素进行旋转操作，表8-3显示了关于旋转操作的相关函数。

表8-3 旋转操作函数

续表

关于旋转操作的时候，这里涉及一个变形的基准点，也就是元素围绕哪一个点进行旋转操作。3D 空间中可以通过向量指定， 2D 空间中就需要使用这个基准点了，该基准点通过transform-origin指定，使用方式为：

transform-origin：[X|Y]

X、Y表示的是相对于元素包围盒子左上角的位置，X、Y可选的值有：

<percentage> | <length> | left | center | right | top | bottom

比如：

-webkit-transform-origin:5px 5px;

表示基准点在相对元素坐上角（5，5）的位置。

-webkit-transform-origin:left bottom;

表示基准点在左下角。

默认情况下是设置在元素的中心位置。

比如，以下代码显示了点击层，该层将顺时针旋转45°。

<!DOCTYPE html>

<meta charset="utf-8">

<style type="text/css">

#bg1{

width:100px;

height:100px;

background-color:blue;

}

.rot{

transform:rotateZ(45deg);

-webkit-transform:rotateZ(45deg);

}

</style>

<html>

<body>

<div id="bg1"/>

</body>

<script src="../js/jquery.js"></script>

<script>

$(document).ready(function(){

$("#bg1").on("click", function(){

$(this).toggleClass("rot");

});

})

</script>

</html>

（4）倾斜。用于对HTML元素进行倾斜操作，表8-4显示了关于倾斜操作的相关函数。

表8-4 旋转操作函数

这个方法表示元素往X、Y轴方向倾斜，注意，倾斜的时候可以使用transform-origin指定倾斜点。

关于变形就说到这里，事实上，当元素从最开始的状态到最后变形状态之间没有非常平滑的过渡效果，几乎就是瞬移的，接下来的内容就是关于如何在多个状态中平滑的切换效果，需要使用到过渡。

8.3.2 过渡

过渡效果是一种相对简单的动画效果，设置了过渡效果后，元素的某一种属性将会从一个值平滑过渡到另一个值，这样，就会显现出一种动画效果，使用过渡效果需要使用如表8-5的相关属性。

表8-5 过渡效果相关属性

比如，如果希望当鼠标移动到div上时，0.5秒之后使得div在1秒内由白色变成红色，以Chrome为例，则可以采用以下方式定义动画：

<style type="text/css">

div{

transition-property:background-color;

transition-duration:1s;

transition-timing-function:linear;

transition-delay:.5s;

}

div:hover{

background-color:red;

}

</style>

除了使用这种方式定义过渡外，还可以采用简写的形式，简写的用法如下：

transition:property||duration_time ||timing_function||delay_time

所以对于上述例子可以简化为以下形式：

<style type="text/css">

div{

transition:background-color 1s linear .5s;

}

div:hover{

background-color:red;

}

</style>

除了对单个属性实现动画外，还可以实现对多个属性同时实现动画，其使用方式为：

<single-transition> [","<single-transition> ]*

single-transition表示单个属性过渡，多个属性使用的时候使用","分割开来，如果希望所有的属性都进行过渡变化，可使用"all"表示。

8.3.3 案例：手风琴效果

接下来，我们可以看一个简单的例子，将使用这些动画效果实现图8-1所示的手风琴效果。

图8-1 手风琴效果

这里，我们实现如下的效果。

（1）开始的时候3个栏目都会层叠起来。

（2）选中一个栏目，该栏目的具体内容将会展开。

接下来，我们看如何实现。

首先，我们完成主体内容部分，也就是完全没有样式的内容如下：

<!DOCTYPE html>

<head>

<meta charset="utf-8">

<meta name="viewport" content="width=device-width, initial-scale=1">

</head>

<html>

<body>

<div id="p1" class="page">

<section id="s1">

<h1><a href="#s1">第一项</a></h1>

<article>

<h2>生命</h2>

<p>

生命中，不断地有人离开或进入。于是，看见的，看不见的；记住的，遗忘了。生命中，不断地有得到和失落。于是，看不见的，看见了；遗忘的，记住了。然而，看不见的，是不是就等于不存在？记住的，是不是永远不会消失？

</p>

</article>

</section>

<section id="s2">

<h1><a href="#s2">第二项</a></h1>

<article>

<h2>平常心</h2>

<p>

一个人最难得的是平常心，得而不喜，失而不忧，内心宁静，则幸福常在；成而不骄，败而不馁，心灵和谐，则快乐常存。很多人之所以痛苦，在于追求错误的东西，更多人都是辛辛苦苦时，过舒服日子；舒舒服服时，过的却是辛苦日子。奋斗过后的痛苦莫过如此

</p>

</article>

</section>

<section id="s3">

<h1><a href="#s3">第三项</a></h1>

<article>

<h2>遇事不钻牛角尖</h2>

<p>

日出东海落西山，愁也一天，喜也一天；遇事不钻牛角尖，人也舒坦，心也舒坦。

</p>

</article>

</section>

</div>

</body>

</html>

这里，我们在meta 部分使用了<meta name="viewport"content="width=device-width, initial-scale=1">，表示的含义是默认窗口全屏，这个参数主要针对移动设备浏览器使用。

然后使用了3个section部分分别包含三段小文章，并且将3个section部分包含在一个div容器中间。

加上相应的样式，首先把整个div层设置为全屏幕大小：

.page{width:100%;position:fixed;height:100%;overflow:hidden;background-color:#eee;}

接下来，实现3个栏目的内容样式，也就是section部分：

#p1 section{

width:100%;

margin:3px auto;

overflow: hidden;

height:36px;

color:#eee;

transition:all .3s;

-webkit-transition:all .3s;

}

此时，把整个section宽度设置为100%，表示和窗口同宽，高度设置为36px，超出的文本设置为hidden方式，文字的颜色设置成和背景色相同。此时，无法看到section里面的内容，只能看到主标题部分，同时使用了transition过渡。

接下来，看看section中的H1标题头部分。

#p1 h1{

margin:0px;

border:3px solid black;

background-color:black;

border-radius:10px;

color:white;

text-align:center;

cursor:pointer;

}

本质上来说，动画的效果就是通过修改元素样式实现的，为了达到用户通过点击标题栏的时候切换样式，这里使用了target伪类选择器，还记得target的用法吧？当某一个元素作为超链接使用的时候，该元素被选择，所以，我们在h1中增加了一个超链接标记，该标记的样式如下：

#p1 section h1 a{

width:100%;

display: block;

color:inherit;

text-decoration: none;

}

接下来设置内容部分，比如子标题和主体内容的样式：

#p1 h2{

text-align:center;

}

#p1 p{

text-indent: 30px;

}

最后，实现剩下的动画效果，也就是当用户点击了标题栏的时候，需要修改section部分的样式，代码如下：

#p1 section:target{

height:200px;

color:black;

}

#p1 section article{

-webkit-transform:scale(0.1);

-webkit-transition:all .8s;

}

#p1 section:target article{

-webkit-transform:scale(1);

}

这里设置了两个动画效果，一个是section部分，section的height部分加长，同时字体颜色变成黑色，用户选择标题栏的时候，这两个属性就会产生过渡效果。另一个动画效果是把section中的article部分，也就是正文部分的内容实现逐渐放大的效果。

关于过渡动画就了解到这里，过渡动画能实现相对简单的动画，如果需要实现更复杂的动画，比如关键帧动画就需要使用Animations了。

8.3.4 动画

在前面的过渡效果中，我们看到了简单的动画效果，但使用过渡动画的时候，它实现动画的本质上就是指定一个属性的开始和结束值，然后通过插值的方式产生一个符合当前时间的属性值，所以就形成了动画。但这种动画的效果比较简单，如果需要实现更为复杂的效果则是不够的。

比如，假设需要一个元素按照某一个路径移动，最后回到原位置，或者实现一个跳动的小球，这样就需要使用animations了。animations是通过设置关键帧来完成动画效果的，可以在整个动画的不同时间设置关键帧。所谓关键帧指的是物体运动或变化中的关键动作所处的那一帧，这一帧的数据是无法通过计算得出的，而在关键帧和关键帧之间可以通过插值的方式产生数据。在如图8-2所示的动画中，圆形物体按照虚线从A移动到D，最后回到A，这里就涉及圆形物体所处的4个状态点，这四个点就是整个动画中的关键帧点。

图8-2 沿虚线移动的物体

创建动画的时候，基本上可以归纳为3个步骤。

1．设置动画属性

animation动画相关的属性如表8-6所示。

表8-6 动画效果相关属性

比如，我们定义了一个id=ball的名称为move的动画属性如下：

#ball {

animation-name: move;

animation-duration: 1s;

animation-timing-function: linear;

animation-delay: 1s;

animation-iteration-count: infinite;

animation-direction: normal;

}

除了使用这种方式定义外，还可以采用以下简写的方式定义动画属性：

animation-name||duration_time||animation-timing-function||delay_time>||animation-iteration-count||animation-direction||animation-fill-mode||animation-play-state

比如，以上动画可写成：

#ball{

animation:move 1s linear 1s infinite normal;

}

定义好了动画的各项属性之后，可以进行下一个步骤。

2．创建关键帧

可以通过@keyframes样式创建一个关键帧集合，然后把这个关键帧集合绑定到定义好的动画上。

在关键帧集合中可以通过关键帧选择器定义每一个关键帧，关键帧选择器可被声明为 from （相当于0%）、to（相当于100%）以及一个或多个百分比。

比如，我们定义一个层的移动动画，开始位置在（100，100），中间的时候位于（200，300），结束的时候位于（300，100），则可以定义如下：

@-webkit-keyframes move{

from {

left:100px;

top:100px;

}

50% {

left:200px;

top:300px;

}

to {

left:300px;

top:100px;

}

}

3．应用动画

定义完关键帧集后，就需要对元素应用动画样式了，通常，这是由事件来触发的。如单击一下按钮或者鼠标悬浮，需要注意的是CSS动画不能通过更改动画的属性值来中断，只能通过使用 animation-play-state 属性或者添加或删除动画名称来中断。

8.3.5 案例：沿路径移动的小球

接下来，我们实现几个小的动画效果，第一个就是如图8-2所示的沿虚线移动的小球。

首先，我们可以通过以下样式来产生一个小球：

#ball{

position:absolute;

width:60px;

height:60px;

border-radius:30px;

background: -webkit-radial-gradient(25px 20px, circle farthest-corner,

white 4%, black 75%)

}

通过以上样式，产生了一个具有立体效果的小球。

接下来设置小球移动的属性，我们希望小球在3秒中内按照图8-2所示的图沿虚线移动一周，属性的样式设置如下：

-webkit-animation:move 5s linear both;

然后，设置关键帧动画：

@-webkit-keyframes move{

from {

left:100px;

top:100px;

}

25% {

left:400px;

top:100px;

}

50%{

left:400px;

top:400px;

}

75%{

left:100px;

top:400px;

}

to {

left:100px;

top:100px;

}

}

这里，涉及小球的5个关键帧，分别对应小球所处5个位置，注意，小球最终会回到初始位置。

最终的代码如下所示：

<!DOCTYPE html>

<head>

<meta charset="utf-8">

<meta name="viewport" content="width=device-width, initial-scale=1">

</head>

<style type="text/css">

body, div{margin:0px;padding:0px;}

#ball{

position:absolute;

width:60px;

height:60px;

border-radius:30px;

background: -webkit-radial-gradient(25px 20px, circle farthest-corner,

white 4%, black 75%);

-webkit-animation:move 5s linear both;

}

@-webkit-keyframes move{

from {

left:100px;

top:100px;

}

25% {

left:400px;

top:100px;

}

50%{

left:400px;

top:400px;

}

75%{

left:100px;

top:400px;

}

to {

left:100px;

top:100px;

}

}

</style>

<html>

<body>

<div id="ball"></div>

</body>

</html>

接下来，来看一个稍微复杂的例子，一个跳动的小球。

8.3.6 案例：地板上弹跳的小球

这里我们完成一个在地板上弹跳的小球，小球将跳动5次，最后停留在地板上，最终效果如图8-3所示。

图8-3 地板上跳跃的小球

首先，在整个窗口的底部放置一个层表示地板，为了具有透视效果，可以使用 perspective样式，整个地板的样式如下：

.con {

width:100%;

height:80%;

position:absolute;

bottom:0px;

-webkit-perspective: 1000px;

}

.floor{

height:100%;

width:100%;

background-image:url(../imgs/f2.jpg);

-webkit-transform:rotateX(45deg);

-webkit-transform-origin:bottom;

}

con类样式将会绑定给一个div，该div位于整个窗口的底部，然后嵌套一个类样式为floor的div，floor设置了一个有背景图片的层，并且围绕底部旋转45°角。

好了，地板设计好了，接着，就需要实现小球跳跃的动画效果。根据需要，这里设置小球跳跃5次就会停留在地板上面，小球每次跳跃在底部和最高处可以分别设置2个关键帧，所以一共有10个关键帧。

需要注意的是，由于小球每次弹起的时候高度会有所降低，所以小球弹起的高度也就是top值设置为逐渐减少至和底部相同即可；另外一个需要注意的地方就是，小球落下时速度是逐渐增加的，此时，可以设置随时间变换的函数为ease-in，反之，当小球弹起时可以设置时间函数为ease-out。

理解以上的分析过程，就可以很容易得到以下的完整代码了：

<!DOCTYPE html>

<head>

<meta charset="utf-8">

<meta name="viewport" content="width=device-width, initial-scale=1">

</head>

<style>

body, div{margin:0px;padding:0px;}

body{

background-color:gray;

}

.con {

width:100%;

height:80%;

position:absolute;

bottom:0px;

-webkit-perspective: 1000px;

}

.floor{

height:100%;

width:100%;

background-image:url(../imgs/f2.jpg);

-webkit-transform:rotateX(45deg);

-webkit-transform-origin:bottom;

}

.ball{

position:absolute;

width:40px;

height:40px;

left:49%;

top:60px;

border-radius:20px;

background: -webkit-radial-gradient(20px 12px, circle farthest-corner,

white 4%, black 70%);

-webkit-animation:jump 1.6s both;

}

@-webkit-keyframes jump {

from

{

-webkit-animation-timing-function:ease-in;

}

10%

{

top:260px;

-webkit-animation-timing-function: ease-out;

}

20%

{

top:100px;

-webkit-animation-timing-function: ease-in;

}

30%

{

top:260px;

-webkit-animation-timing-function: ease-out;

}

40%

{

top:140px;

-webkit-animation-timing-function: ease-in;

}

50%

{

top:260px;

-webkit-animation-timing-function: ease-out;

}

60%

{

top:180px;

-webkit-animation-timing-function: ease-in;

}

70%

{

top:260px;

-webkit-animation-timing-function: ease-out;

}

80%

{

top:220px;

-webkit-animation-timing-function: ease-in;

}

90%

{

top:260px;

-webkit-animation-timing-function: ease-in;

}

to{

top:260px;

-webkit-animation-timing-function: ease-in;

}

}

</style>

<html>

<div class="con">

<div class="floor"/>

</div>

<div class="ball" />

</html>

这是一个相对复杂点的动画，当然，关键帧越多，动画的细节表现得越好。关于CSS动画相关的内容先了解到这里。虽然，CSS3表现动画方面有了相比CSS2更多优秀的地方，但完全要把CSS运用到游戏当中去，还需要结合js语言描述游戏逻辑。

接下来，我们就使用js结合CSS样式完成一个经典的游戏——《俄罗斯方块》。

8.4 案例：俄罗斯方块

20世纪八九十年代风靡全世界的游戏中，俄罗斯方块毫无疑问是其中最耀眼的一个，作为消方块类型游戏的始祖游戏，其高度的可玩性和挑战性至今让许多人津津乐道。

俄罗斯方块原本是前苏联科学家阿列克谢·帕基特诺夫在 1984 年 6 月利用空闲时间所编写的游戏程序，据说，他是一个网球运动的热爱者，他把来源于希腊语的tetra和网球（Tennis）结合起来，创造了Tetris一词，也就是后来在各大游戏平台中很受欢迎的游戏——俄罗斯方块。该游戏后来几乎成为了各个游戏平台标配的游戏，为了表彰帕基特诺夫在小游戏领域做出的突出贡献，游戏开发者大会（GDC）在 2007 年授予帕基特诺夫第一金企鹅奖，该奖项地位就如同奥斯卡的终生成就奖。在2009年的吉尼斯世界纪录全球10款最佳游戏中，俄罗斯方块击败了众多具有华丽视觉效果和震撼音效的游戏，夺得了第2名的位置，而由任天堂在SFC上推出的《玛丽奥赛车》则成为吉尼斯纪录认证的史上最佳游戏。

如果算上所有的游戏机种的话，俄罗斯方块游戏在全球的销量毫无疑问是最多的。据统计，截至2009年，俄罗斯方块发售了1.25亿份，受到50多个国家和地区的玩家喜爱，有超过50种语言的版本，运行在几十种游戏平台上，从掌机、街机、个人电脑，到手机，特别是在 20世纪80 年代作为任天堂在便携游戏机Game Boy捆绑销售的游戏，为Game Boy拿下和巩固了近10年的便携机市场立下了汗马功劳。

本节中，我们将会实现这个充满乐趣而又伟大的游戏。

8.4.1 游戏规则和游戏UI

如图8-4展示了俄罗斯方块的一种实现，文字部分标出了游戏中的各个主要组成部分。

图8-4 Tetris游戏UI

游戏的规则十分简单，大体上的规则如下。

（1）一共有7种不同形状的砖块从游戏操作区域自由向下移动一个小格，每种砖块由4个正方形的小方块组成。

（2）在砖块落下的过程中，玩家可以旋转90°、左右移动砖块，每次以一个小格子为单位进行移动。

（3）当砖块落到游戏操作区最下方或者落到其他的砖块上，则砖块停止移动，游戏产生一个新的砖块从游戏操作区域落下。

（4）当游戏操作区某一横行被砖块全部填满，则该行可以消去，位于该行上面的所有行将落下，玩家可以得分，随着分数的增加，游戏级别上升，游戏级别越高，砖块下落速度越快。

（5）如果固定区域的砖块已经到达顶端而无法消除的时候，游戏就结束。

以上是俄罗斯方块的游戏规则，了解游戏规则后，我们先来完成游戏的UI原型部分。

利用HTML和CSS，根据游戏的UI布局，以下是其中的一种实现方式：

<div id="ter">

<!--游戏操作区-->

<table id="tb"></table>

<!--操作区右边面板-->

<div id="pan">

<div id="lab_lev">Level</div>

<div id="lev">0</div>

<div id="lab_score">Score</div>

<div id="score">0</div>

<!--预览区-->

<table id="pre"></table>

<input type="button" id="btn_res" value="Reset" " /><br>

<input type="button" id="btn_pau" value="Pause" />

</div>

</div>

整体布局上来说，分为左右两个区域，左边的区域是游戏的主要操作区域，右边用于显示游戏进行过程中的各种信息。实现游戏操作区域的方式有很多，这里，我们使用table来作为游戏的操作区域和砖块预览区，后面的小节中会介绍如何在游戏区域和预览区显示方块。

这里使用到了CSS实现文字阴影效果以及背景色的渐变效果，其中文字的阴影效果用法如下：

text-shadow:length length length color

其中前两个length分别表示阴影相对于文字的X、Y方向的距离，第三个length表示阴影的模糊半径，最后一个是阴影的颜色。

背景色的渐变效果可以使用gradient属性，以下是在Chrome中使用渐变效果的用法：

background:-webkit-gradient(type, x1 y1, x2 y2, from(开始颜色值), to(结束颜色值),[color-stop(偏移量小数, 停靠颜色值), ...] );

其中的参数表示意义如下。第一个参数type，可以是linear（线性渐变）或者radial（径向渐变）；第二个和第三个参数，都是一对值，分别表示渐变起点和终点，该值可以用坐标形式表示，也可以用关键值表示，比如left top（左上角）和left bottom（左下角）；第四个和第五个参数，分别是两个color-stop函数。color-stop函数接受两个参数，第一个表示渐变的位置， 0为起点，0.5为中点，1为结束点，也可以使用百分比表示，第二个表示该点的颜色。

比如，以下代码创建了一个从左到右、黑色渐变为白色的样式：

background:-webkit-gradient(linear, left center, right center, color-stop(0%, rgba(0, 0, 0, 1)), color-stop(100%, rgba(255, 255, 255, 1)));

实现的效果如图8-5所示。

图8-5 背景渐变效果

最终，整个样式和布局可参考完整的游戏实现部分，接下来，先来对游戏做一些分析。

8.4.2 分析游戏

我们先来分析俄罗斯方块中砖块的实现，在俄罗斯方块中，一共有7种形状的方块，这7种方块分别对应着7个字母的形状：I、J、L、O、S、Z、T，如图8-6所示。

图8-6 7种方块

每种方块由4种小的正方形格子组成，同时，在游戏运行过程中，玩家还可以旋转不同的方块。每种方块旋转后的形状也不一样，由于每个方块的形状最多为横向4个格子（如横向的I）以及纵向的4个格子（比如纵向的I），所以我们使用一个4×4的表格就可以描述所有的情况。我们以T为例，图8-7展示了T的4种不同的形态。

图8-7 T形方块的4种形态

当然，由于T形砖块横向和纵向的最大格子数为3，所以最下行会空行，但对与I形的砖块来说就不同了。

接下来的工作，我们需要使用合适的数学模型来描述一个砖块，还记得之前我们在介绍碰撞检测算法使用的地图格子算法吗？当时我们把地图划分为若干格子，障碍用 1 表示，其他可通过的区域用 0 表示。这里，我们采用同样的方法描述砖块，可以使用4×4的二维数组表示，1表示砖块的组成部分，0表示没有。

图8-8 T形方块的数组描述

按照这样的描述方法，则正常状态下的T砖块可以用图8-8的二维数组描述。

当然，采用这种方式描述砖块比较浪费空间，事实上我们可以把数组的每一行用十六进制数据描述，比如第1行0100可以表示成0x4，第2行表示为0xe，这样整个二维数组就可以使用一个十六进制数据0x4e00表示，每4位表示一行数据，从高到低分别表示1～4行的数据，这样，T型砖块的所有状态可以使用4个数据表示：0x4e00、0x4640、0xe40、0x4c40。

同样的道理，其他的砖块也可以使用十六进制数据描述，最后，我们可以使用一个二维数组表示7种转块的所有形态，比如：

var block = [[0x4444, 0xf00], [0x4620, 0x6c0], [0x2640, 0xc60], [0x6600], [0x6220,

0x1700, 0x2230, 0x740], [0x6440, 0xe20, 0x44c0, 0x8e00], [0x4e00, 0x4640,

0xe40, 0x4c40]];

有了描述砖块的数据，接下来看看如何渲染砖块。

前面在游戏规则和 UI 中介绍了，使用表格作为游戏操作区域和预览区域，先来看看预览区域中如何绘制砖块。

预览区域中将会显示游戏中产生的下一个方块，在预览区域中我们将使用4行4列的表格来进行显示，正好4行4列的表格足以显示7种方块的所有状态。我们把一个砖块数据看成一个4×4的二维数组，那么，表格的单元格中正好和这个二维数组的数据对应，在设计表格样式的时候，我们只需把二维数组中为1的数据设置样式，为0的部分设置为透明或者和底色相同，这样就会显示出方块了。

这里需要注意的是，方块的数据使用的是十六进制，需要分解这个数据，根据某行某列的值来判断是否给单元格设置样式，可以通过：(data>>(4-r)*4)&0xf获取第r行的数据，data表示砖块的二进制数据，获取第c列的数据可以通过(rData>>4-c)&0x1得到，rData表示第r行的数据。比如，对于T型数据0x4e00，获取第1行的数据，通过(0x4e00>>12)&0xf可以得到0x4，然后可以通过(0x4>>3)&0x1得到第1行1列的数据。

以下代码片段展示了显示预览区域的方块：

//绘制预览砖块

for(var i=0;i<4;i++)

{

for(var j=0;j<4;j++)

{

var b=(((this.nCData>>((3-i)<<2)&0xf)>>(3-j))&0x1);

pre.rows[i].cells[j].className = b?this.nCol:"";

}

}

其中，this.nCData表示预览区的砖块数据，pre是预览区表格，this.nCol是具有颜色的单元格样式。

有了这个简单的案例做基础，接下来看看稍微复杂点的，也就是如何在游戏操作区进行显示。

正如前面使用表格显示预览砖块一样，我们同样使用表格来显示游戏操作区域，7 种砖块需要使用数据来存储，同样，对于游戏操作区域一样需要使用数据来存储，这里，我们使用20×12，即20行12列的二维数组来存储游戏操作区域。初始的时候，这个二维数组的所有数据都是0，表示没有砖块，如果该位置的数据为1，则表示该位置已经有砖块了，假设，图8-9显示的游戏操作区域第1行的图形，我们可以使用000011100010来表示。同样地，如果直接使用二维数组存储则浪费空间，我们可以采用十六进制保存数据，图8-9可以使用0x0e2表示，于是，对于20×12的二维数组可以使用一个有20个数据的一维数组表示，每一个数据表示一行。

图8-9 第1行图形

接下来，我们需要处理一个砖块在操作区域的移动了，在游戏进行过程当中，玩家可以使用按键移动砖块，先来看看图8-10的情况，如何进行处理。

图8-10 运行界面

这里显示了游戏运行中前4行的情况，此时，最右边的4行4列由大方框表示的是当前正操作的方块，我们以砖块左上角为砖块原点，此时砖块在操作区域的位置为（0，8)，即第1行，第9列。很明显，此时，方块是无法向左移动，因为，方块的第2行左边已经有被其他格子占用了，如果左移动，将会导致该方块和操作区域有重叠。

所以对于方块是否能够移动我们可以采用以下步骤判断。

（1）获取当前操作砖块的第1行数据。

（2）判断该行和所在操作区域的行是否有重叠。

（3）如果有重叠则表示无法移动，退出。

（4）获取当前操作砖块的下1行数据，重复步骤2～4，直到4行处理完毕。

这里，稍微有点麻烦的是第2步，如何判断该行和所在操作区域的行是否有重叠。举个例子，图8-10中，第2行的操作区域的数据是000011110000，用十六进制表示是0xf0，此时砖块第2行的数据是0x60，如果满足条件：(data1|data2)!=(data1^data2)，即，把两个数据分别按位或和异或，如果不相等则可以判断这两个数据是否有重叠，因为判断重叠，实质上是判断相同位置的数据是否都为1即可。为了避免砖块移动到操作区域两边边界处不好处理，所以我们使用16列表示每一行操作区域数据，即每一行的两边分别多加了两列，该处的数据用1表示，同时，也在操作区域中最下列增加了两行，该处的数据用0xffff表示底部边界，所以最终操作区域的数据有22行16列组成。

理解了上面的原理，对于任意一个方块是否可以移动到操作区域的某一行某一列，可以采用以下方法处理：

//检查是否可以移动到r行c列位置

canMoveTo:function(r, c, data)

{

if(c>12)return false;

for(var i=0;i<4;i++)

{

var d1=((data>>((3-i)<<2)&0xf)<<(12-c)),

d2 = this.map[r+i];

//判断是否可移动

if((d1|d2)!=(d1^d2))return false;

}

return true;

}

该方法返回一个布尔值，用于检查是否可以移动到操作区域的r行c列位置，这里需要注意的是，砖块的某一行数据获取之后，还需要进行左移相应的位数以便和当前所在行位置相匹配。

砖块是否可以移动处理完毕，接下来，需要做的是当一个砖块不能向下移动的时候，此时，需要把砖块当前的数据和操作区域的数据合并以产生新的地图，同时，需要产生一个新的砖块，这里处理比较容易。

判断砖块是否能继续往下移动，只需要调用canMove（砖块的当前行+1，砖块的当前列，砖块数据）即可。

一旦判断砖块无法向下继续移动，我们就需要采用以下步骤，合并当前的操作区域数据和砖块数据。

（1）获取当前操作砖块的第1行数据。

（2）把该行的数据和所在的操作区域行的数据按位相或，赋值给该行。

（3）获取当前操作砖块的下1行数据，重复步骤2～步骤3，直到4行处理完毕。

接下来的工作就是产生一个新的砖块了，事实上，我们可以在表示所有砖块的二维数组中取出一个随机的数据就可以了，相信这部分的处理应该不是难事了。

还差一个需要处理的是当操作区域的某一行被砖块全部填满时，该行被消除，事实上，只要判断操作区域的某一行数据为 0xffff 了，就表示该行被砖块全部填满了，此时我们需要把该行的数据从数组中删除，然后在数组的第1行增加新的空数据就可以了。

最后，关于分数和级别的处理，可以根据游戏逻辑自行处理。这里，我们可以根据消除的行数作为玩家得到的分数，每消除1行得到1分，当每得到20分时，就自动升级，当然，升级之后游戏的速度会随之增加。

经过以上分析之后，大体上，游戏可以采用以下伪代码形式处理：

//设定定时处理

setInterval(游戏主函数(), 时间);

//游戏主函数

游戏主函数()

{

砖块移动函数(当前行+1, 当前列, 当前砖块数据);

}

砖块移动函数(行, 列, 当前砖块数据)

{

判断是否可以移动到指定行、列，如果可以则

重新设置当前砖块数据以及行、列

否则

如果新行>砖块当前行，表示该砖块已经到底或者碰到其他砖块而无法向下移动，则

1.合并游戏操作区域数据以及砖块数据

2.如果可以消除一行，则消除一行数据

3.创建新的砖块

渲染游戏操作区域函数()

}

渲染游戏操作区域函数()

{

1.根据当前游戏操作区域数据，渲染游戏操作区域

2.渲染预览区砖块

3.渲染当前分数、等级

}

//绑定按键事件

document.onkeydown=

{

上：砖块移动函数(当前行, 当前列, 当前砖块旋转90 后的数据);

下：砖块移动函数(当前行+1, 当前列, 当前砖块数据);

左：砖块移动函数(当前行, 当前列-1, 当前砖块数据);

右：砖块移动函数(当前行, 当前列+1, 当前砖块数据);

}

接下来，我们来完整地实现它。

8.4.3 游戏实现

在前面几节分析的基础之上，我们可以得到游戏的完整实现了。

<!DOCTYPE html>

<head>

<meta charset="utf-8">

<meta name="viewport" content="width=device-width, initial-scale=1">

</head>

<style>

body, div{margin:0px;padding:0px;}

body{

background-color:#eee;

}

.c1{

border:3px #bb3627 outset;

background:-webkit-gradient(linear, left top, right bottom, color-stop(0%, #efc9c5),

color-stop(50%, #bb3627), color-stop(50%, #d2594c), color-stop(100%, #f1998f));

}

.c2{

border:3px #4cd24c outset;

background: -webkit-gradient(linear, left top, right bottom, color-stop(0%, #c5efc5),

color-stop(50%, #4cd24c), color-stop(51%, #27bb27), color-stop(100%, #8ff18f));

}

.c3{

border:3px #4247d8 outset;

background: -webkit-gradient(linear, left top, right bottom, color-stop(0%, #d0d1f2),

color-stop(50%, #4247d8), color-stop(51%, #6f72db), color-stop(100%, #a6a9f4));

}

.c4{

border:3px #727272 outset;

background: -webkit-gradient(linear, left top, right bottom, color-stop(0%, #dadada),

color-stop(50%, #727272), color-stop(51%, #909090), color-stop(100%, #c1c1c1));

}

.c5{

border:3px #f355f3 outset;

background: -webkit-gradient(linear, left top, right bottom, color-stop(0%, #fbd3fb),

color-stop(50%, #f355f3), color-stop(51%, #f47af4), color-stop(100%, #fcb2fc));

}

.c6{

border:3px #e1e1e1 outset;

background: -webkit-gradient(linear, left top, right bottom, color-stop(0%, #ffffff),

color-stop(50%, #f1f1f1), color-stop(51%, #e1e1e1), color-stop(100%, #f6f6f6));

}

.c7{

border:3px #e9de07 outset;

background: -webkit-gradient(linear, left top, right bottom, color-stop(0%, #fefaae),

color-stop(50%, #f1e831), color-stop(51%, #e9de07), color-stop(100%, #fbf35b));

}

#tb{

float:left;

border-spacing:0;

border:2px gray inset;

}

#tb td{

box-sizing:border-box;

width:24px;

height:24px;

}

#tb .htd{

width:0px;

height:0px;

padding:0px;

margin:0px;

}

#pan{

float:left;

width:124px;

height:100%;

}

#lab_lev, #lab_score{

margin-top:10px;

width:100%;

font-size:36px;

font-weight:bold;

color:rgb(192, 192, 192);

text-shadow:-4px -4px 8px black;

text-align:center;

}

#lev, #score{

text-align:center;

width:100%;

font-size:36px;

font-weight:bold;

text-shadow:-4px -4px 8px black;

color:red;

}

#pre{

margin:8px auto;

background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(0,

0, 0, 0.65)), color-stop(100%, rgba(0, 0, 0, 0)));

border:1px gray inset;

}

#pre td{

box-sizing:border-box;

width:16px;

height:16px;

}

#ter{

margin:0px auto;

text-align:center;

overflow:hidden;

width:420px;

border:4px gray inset;

background: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(125,

126, 125, 1)), color-stop(100%, rgba(14, 14, 14, 1)));

}

#btn_pau, #btn_res{

margin-top:20px;

width:100px;

height:30px;

font-size:20px;

font-weight:bold;

color: rgb(128, 128, 128);

text-shadow: -2px -2px 0 #fff, 1px 1px 0 #333, 1px 1px 0 #444;

background: -webkit-gradient(linear, left top, right bottom, color-stop(0%, #ffffff),

color-stop(50%, #f1f1f1), color-stop(51%, #e1e1e1), color-stop(100%, #f6f6f6));

}

</style>

<body>

<div id="ter">

<!--游戏操作区-->

<table id="tb"></table>

<!--操作区右边面板-->

<div id="pan">

<div id="lab_lev">Level</div>

<div id="lev">0</div>

<div id="lab_score">Score</div>

<div id="score">0</div>

<!--预览区-->

<table id="pre"></table>

<input type="button" id="btn_res" value="Reset" onclick="location.reload()" /><br>

<input type="button" id="btn_pau" value="Pause" onclick="javascript:this.value=

['Start', 'Pause'][G.np=!G.np|0]" />

</div>

</div>

</body>

<script>

var G={

//所有的砖块数据

sData:[[0x4444, 0xf00], [0x4620, 0x6c0], [0x2640, 0xc60], [0x6600], [0x6220,

0x1700, 0x2230, 0x740], [0x6440, 0xe20, 0x44c0, 0x8e00], [0x4e00, 0x4640, 0xe40, 0x4c40]],

//操作区数据

map:[0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xc003,

0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xc003, 0xffff, 0xffff],

//初始化

init:function(){

for(var i=0;i<22;i++)

{

var tr=document.createElement("tr");

for(var j=0;j<16;j++)

{

var td=document.createElement("td");

if(i>19||j<2||j>13)td.className="htd";

tr.appendChild(td);

}

tb.appendChild(tr);

}

//初始化预览方块

for(var i=0;i<4;i++)

{

var tr=document.createElement("tr");

for(var j=0;j<4;j++)

{

var td = document.createElement("td");

tr.appendChild(td);

}

pre.appendChild(tr);

}

//绑定按键事件

document.onkeydown=function(e){

G.keyHandler(e);

}

//定义分数、级别

this.score = this.level = 0;

//暂停标记

this.np = 1;

},

//检查是否可以移动到(r, c)位置

canMoveTo:function(r, c, data)

{

if(c>12)return false;

for(var i=0;i<4;i++)

{

var d1=((data>>((3-i)<<2)&0xf)<<(12-c)),

d2 = this.map[r+i];

//判断是否可移动

if((d1|d2)!=(d1^d2))return false;

}

return true;

},

//移动到r行c列

moveTo:function(r, c, data)

{

if(this.canMoveTo(r, c, data))

{

this.cData=data;

this.r = r;

this.c = c;

}

else{

//无法继续向下移动

if(r>this.r)

{

for(var cr=0;cr<4;cr++)

{

var tr=this.r+cr,

d = (data>>((3-cr)<<2)&0xf);

if(tr<0||tr>19)continue;

this.map[tr]|= d<<(12-c);

for(var cc = 0;cc<4;cc++)

{

var m=(d>>(3-cc))&0x1,

ct = this.c+cc;

if(m)tb.rows[tr].cells[ct].className = this.col;

}

if(this.canClear(tr)){

this.clearLine(tr);

}

}

this.createBlock();

}

}

this.render();

},

//处理按键事件

keyHandler:function(e)

{

switch(e.keyCode)

{

case 38:

this.i = (this.i+1)%this.sData[this.t].length;

this.moveTo(this.r, this.c, this.sData[this.t][this.i]);

break;

case 40:

this.moveTo(this.r+1, this.c, this.cData);

break;

case 37:

this.moveTo(this.r, this.c-1, this.cData);

break;

case 39:

this.moveTo(this.r, this.c+1, this.cData);

break;

}

},

//判断第x行是否可消除

canClear:function(x)

{

return x<20&&this.map[x]==0xffff;

},

//消除第x行

clearLine:function(x)

{

this.map.splice(x, 1);

this.map.splice(0, 0, 0xc003);

this.score++;

this.level = this.score/20|0;

},

//绘制

render:function()

{

var cs="";

//绘制背景

for(var i=0;i<20;i++)

{

for(var j=2;j<14;j++)

{

cs=this.map[i]&(1<<(15-j));

if(!cs)tb.rows[i].cells[j].className="";

var tr = i-this.r,

tc = j-this.c;

//绘制移动砖块

if(tr>=0&&tr<4&&tc>=0&&tc<4&&(((this.cData>>((3-tr)<<2)&0xf)>>(3-tc))&0x1))

{

tb.rows[i].cells[this.c+tc].className=this.col;

}

}

}

//绘制预览砖块

for(var i=0;i<4;i++)

{

for(var j=0;j<4;j++)

{

var b=(((this.nCData>>((3-i)<<2)&0xf)>>(3-j))&0x1);

pre.rows[i].cells[j].className = b?this.nCol:"";

}

}

//绘制分数

document.getElementById("score").innerText=this.score;

//绘制级别

document.getElementById("lev").innerText=this.level;

},

//产生随机砖块

randBlock:function()

{

//当前砖块索引

this.t = this.nt;

//当前砖块类型索引

this.i = this.ni;

//当前颜色

this.col = this.nCol;

//当前砖块数据

this.cData = this.nCData;

this.nt = (Math.random()*this.sData.length|0);

this.ni = (Math.random()*this.sData[this.nt].length|0);

this.nCol = "c"+(Math.random()*7+1|0);

this.nCData = this.sData[this.nt][this.ni];

},

//产生新的砖块

createBlock:function()

{

this.randBlock();

var i = 0;

while(!(this.cData>>(i<<2)&0xf))i++;

this.r = i-4;

this.c = 6;

if(!this.canMoveTo(this.r+1, this.c, this.cData))

{

alert("Game Over!");

this.stop();

document.onkeydown = null;

}

},

//主循环

main:function()

{

//如果没有暂停

if(!this.np)return;

this.moveTo(this.r+1, this.c, this.cData);

},

start:function()

{

this.init();

this.randBlock();

this.createBlock();

this.hd = setInterval(function(){

G.main();

}, 60*(10-this.level));

},

stop:function()

{

clearInterval(this.hd);

}

}

G.start();

</script>

</html>

8.5 小结

关于CSS的内容先了解到这里，事实上早在HTML4时代，就有很多使用JS结合CSS完成的游戏，随着CSS功能的日趋强大，HTML在游戏的前景将更为广阔。

接下来将进入实战篇，将前面所学到的知识应用到实战中。

第9章 编程实战之太空战机

本章我们将利用前面所学习的xengine开发一款飞行射击类游戏。正如附录A中的游戏发展史所述，飞行射击类游戏还是所有电子游戏的鼻祖。第一台所谓的电子游戏机即由美国人Thomas设计的阴极射线管娱乐装置，就是一款最原始的射击类游戏，后来20世纪60年代广为流传的Space war（宇宙战争）同样也是一款射击类游戏，在20世纪80年代盛行的街机游戏中，飞行射击类游戏为街机的流行立下了汗马功劳，从玩家手中捞取了大笔的银子。基本上，街机的主流游戏就是射击类、格斗类以及赌博类。

接下来我们就开始构思一款纵向的射击游戏的制作。

9.1 创建主框架

首先，我们来设计整个游戏的主体框架，大体上，整个游戏分为以下3个主要的场景。

• 开始场景。

• 运行场景。

• 结束场景。

当然，几乎所有的场景都至少包含以上的3个场景，这3个场景会根据用户和游戏的交互以及游戏运行的逻辑进行切换。图9-1所示的状态转换图展示了一个飞行类游戏中，这3个场景之间的切换。

图9-1 飞行射击游戏转换图

在前面的算法章节中，我们使用状态机管理精灵，开发了一个蚂蚁的小例子。这里，我们同样可以借助于状态机管理3个游戏场景。

所以，我们基本上可以采用下列伪代码创建这3个状态类，这3个状态类基本类似：

var GameState = State.extend({

enter:function()

{

var game=this.ctx.owner;

//创建场景

},

change:function()

{

达到条件，进行场景转换

}

exit:function()

{

删除该场景

}

});

这里，我们不再一开始就创建场景，而是把场景的创建工作放在了状态机的enter方法中，即进入某一个状态的时候就创建一个场景，当某一个状态达到转换条件的时候，比如在开始场景中，如果用户选择了开始场景则转换到游戏进行状态中，最后离开某一个状态的时候，我们把该场景移出。

为了能够使用状态机管理整个游戏的进程，这里我们可以给游戏增加一个监听器，在整个场景的渲染之前更新状态机，这样，就可以根据状态机的变换，在渲染场景前选择正确的场景。

这样，可以得到以下的游戏主体框架：

/*

* 游戏主体框架

*/

(function(win){

var GW = 400,

GH=600,

GxOff = (document.body.clientWidth-GW)*0.5;

//定义游戏开始状态类

var TitleState = State.extend({

enter:function()

{

var game=this.ctx.owner;

//创建开始场景

var scm = game.sceneManager;

var sc = scm.createScene(TitleSC.ClassName, [GxOff, 10, "Title"]);

this.sc = sc;

//加载游戏资源

ResManager.loadRes("data/res.json", function(){

game.cfg = ResManager.getResByName("gCfg", "gf0").data;

sc.hideProgressUI();

sc.holder.find("#sDiv").fadeIn(1000);

},

function(total, cur){

//渲染进度条

var pro = (cur/total)*100|0;

sc.holder.find("#pBar").width(pro*3);

});

},

change:function()

{

var game=this.ctx.owner;

if(this.sc.isStart)

{

game.sCtx.changeState("Gaming");

}

},

exit:function()

{

var scm=this.ctx.owner.sceneManager;

scm.remove("Title");

}

});

//定义运行状态类

var GamingState = State.extend({

enter:function()

{

var game = this.ctx.owner;

//创建开始场景

var scm = game.sceneManager;

this.sc = scm.createScene(GamingSC.ClassName, [GxOff, 10, "Gaming"]);

},

change:function()

{

var game=this.ctx.owner;

if(this.sc.isOver)

{

game.sCtx.changeState("GameEnd");

}

},

exit:function()

{

var scm=this.ctx.owner.sceneManager;

scm.remove("Gaming");

}

});

//定义结束状态类

var GameEndState = State.extend({

enter:function()

{

var game = this.ctx.owner;

//创建结束场景

var scm = game.sceneManager;

this.sc = scm.createScene(EndSC.ClassName, [GxOff, 10, "GameEnd"]);

},

change:function()

{

var game=this.ctx.owner;

if(this.sc.isRetry)

{

game.sCtx.changeState("Title");

}

},

exit:function()

{

var scm=this.ctx.owner.sceneManager;

scm.remove("GameEnd");

}

});

var _shootGame = Game.extend({

//初始化游戏

init:function()

{

//获取场景管理器

this._super();

this.cfg = null;

this.sCtx = new StateContext(this);

this.regState();

this.sCtx.changeState("Title");

this.initListener();

this.run(60);

},

//注册状态类

regState:function()

{

this.sCtx.regState(new TitleState("Title", this.sCtx));

this.sCtx.regState(new GamingState("Gaming", this.sCtx));

this.sCtx.regState(new GameEndState("GameEnd", this.sCtx));

},

//初始化游戏监听器

initListener:function()

{

var self=this;

//增加监听器

var ltn = new AppEventListener({

"beforeRender":function(){

self.sCtx.update();

}

});

this.addListener(ltn);

}

});

//定义全局StickGame

win.ShootGame = new _shootGame();

}(window))

这里，根据游戏的需要，定义了3个游戏状态类，分别是开始状态、运行状态以及结束状态，在每个状态的enter方法中，分别创建了3个场景。需要注意的是，我们以前创建场景的时候是直接创建的Scene的实例，为了便于更细精度的管理场景，这3个场景分别是TitleSC、GamingSC以及EndSC类的实例。

接下来分别看看3个场景的具体实现，首先是游戏主场景。

9.2 创建开始场景

游戏的开始场景可以很简单，也可以很复杂，这得取决于你的想象，当然，还得有时间的允许。这里，我们处理得相对简单一点，如图9-2所示，整个开始场景UI包含一个进度条和一个开始标记的组件。进度条将显示游戏资源加载的进度，当整个游戏的场景加载结束后，将隐藏进度条，同时显示开始标记，当用户点击开始标记的时候，游戏进入运行状态。

图9-2 开始场景

需要注意的是，开始场景TitleSC需要继承至Scene类，这个开始界面的UI相当简洁，当然，你可以找个好的美术伙伴和他一起合作，做得更美观一些。

总体上来说，这个界面比较简单，没有太多的游戏逻辑需要处理。

整个TitleSC代码如下：

/*

* TitleScene 类

*/

(function(){

//从引擎的Scene继承

TitleSC = Scene.extend({

init:function(x, y, name)

{

this._super({"x":x,"y":y,"w":400,"h":600,"name":name});

this.setBGImg("imgs/title.jpg", 1);

this.isStart = false;

this.initUI();

},

//初始化UI

initUI:function()

{

this.initProgressUI();

this.initStartUI();

},

//初始化进度条

initProgressUI:function()

{

var pBar=$("<div id='pCBar' style='position:absolute;border-radius:3px;

border:2px solid yellow;left:50px; top:560px;height:10px;width:

300px;'></div>");

pBar.append("<div id='pBar' style='position:absolute;left:0px;top:0px;overflow:

hidden;background-color:red;width:1px;height:9px;top:1px;'> </div>");

this.holder.append(pBar);

},

//隐藏进度条

hideProgressUI:function()

{

this.holder.find("#pCBar").hide();

},

//初始化StartGame UI

initStartUI:function()

{

var self=this;

var sDiv = $("<div id='sDiv' align='center' style='display:none;cursor:pointer;

position:absolute; border:2px solid white;left:100px;top:300px;

color:white;font-size: 36px; line-height:36px;height:38px;width:

200px;'> Start</div>")

sDiv.click(function(){

self.isStart = true;

})

this.holder.append(sDiv);

}

});

TitleSC.ClassName = "TitleSC";

//注册TitleSC类

ClassFactory.regClass(TitleSC.ClassName, TitleSC);

}())

好了，这个简单的TitleSC的场景类就完结了，注意场景类的isStart属性，这个属性的默认值是false，当用户点击Start标签的时候，这个值就变成了true，于是，在整个游戏的开始状态类TitleState中的change部分的代码就起了作用，此时，游戏将转换到游戏运行场景。

接下来，看看游戏运行场景类的实现，这部分是整个游戏的核心部分，当然，也更为复杂。

9.3 创建运行场景

游戏的运行场景相对来说更为复杂，因为，这个游戏场景中包含了整个涉及的游戏逻辑，我们可以一步一步来实现。首先，我们可以先建立一个最简单的界面，如图9-3所示，游戏界面的最上面显示了玩家当前操作飞机的生命以及分数。

图9-3 运行场景

相信，这个简单的界面实现应该不太复杂，于是，我们可以先实现GamingSC类代码如下，需要注意的是， GamingSC同样需要从Scene继承。

GamingSC初步代码：

/*

* GamingScene 类

*/

(function(){

//从引擎的Scene继承

GamingSC = Scene.extend({

init:function(x, y, name)

{

this._super({"x":x,"y":y,"w":400,"h":600,"name":name});

this.lifeTxt = null;

this.scoreTxt = null;

this.initUI();

},

//初始化UI

initUI:function()

{

this.lifeTxt=$("<divid='pLifeTxt'style='text-align:left;position:absolute;

font-size:26px;color:red;height:40px;width:130px;left:

10px'>Life:0</div>");

this.scoreTxt = $("<div id='pScTxt' style='text-align:left;position:absolute;

font-size:26px;color:red;height:40px;width:130px;left:

260px'>Score:0</div>");

this.holder.append(this.lifeTxt);

this.holder.append(this.scoreTxt);

},

});

GamingSC.ClassName = "GamingSC";

//注册Ball类

ClassFactory.regClass(GamingSC.ClassName, GamingSC);

}())

看起来够简单了。当然，可以根据想象，做得更为绚丽。

接下来，逐渐在这个场景中增加我们需要的东西。首先，背景太过单调，先来添加背景。

9.3.1 创建视差滚动背景

这个场景的背景和开始场景不太一样，开始背景是一张静态的图片，射击游戏里面，一般来说，背景都是不停滚动的。对于纵向射击类来说，假设场景中有一个静态的图片，比如飞机，一旦我们创建一个不断向下滚动的背景，这样看起来，飞机就好像不停向上飞行，这就叫做视差滚动。

视差滚动效果，原本是一个天文学术语，当我们观察星空时，离我们远的星星移动速度较慢，离我们近的星星移动速度则较快。当我们坐在车上向车窗外看时，也会有这样的感觉，远处的群山似乎没有在动，而近处的稻田却在飞速掠过。游戏中常常使用视差效果来增加场景的立体感。

我们实现背景的滚动可以采用以下几种方式。

（1）创建一个比较长的div，在该div中平铺背景图片，然后定时改变div的位置。

（2）创建一个背景移动的精灵，计算需要截取的部分图像，渲染到canvas中。

这里，我们使用第二种方式，把整个背景看作一个精灵类。

在使用第二种方式的时候，我们把屏幕上显示的游戏窗口称为一个视口，然后需要找一个比视口长度长的背景图片，宽度和视口宽度一致，这样，我们不断改变背景精灵类的Y坐标，然后计算出需要把背景的哪一部分绘制到视口中。

如图9-4所示，这里展示了一个背景不断向下移动的场景，黑色部分是需要绘制到视口的部分，需要注意的是第二种情况，当背景滚动后不足以显示到视口中的时候，我们需要把背景底下的部分渲染到视口顶部，注意这两种情况就好说了。

图9-4 视差滚动图

根据这个理论，我们得到了背景精灵Background类的实现：

/*

* Background 类

*/

(function(){

//从引擎的RenderObj继承

Background = RenderObj.extend({

init:function(vW, vH, sH, img)

{

this._super();

this.zIdx = -99;

this.sH = sH;

this.vH = vH;

this.vW = vW;

this.scrollY = 0;

this.img = img;

this.iHeight = img.height;

},

update:function()

{

//滚动y坐标，相对于整个背景

if(this.sH-this.scrollY>this.vH)

{

this.y+=this.dy;

this.scrollY += this.dy;

}

if(this.y>= this.iHeight)

{

this.y=0;

}

},

//重写render

render:function(ctx)

{

var h=this.iHeight-this.y;

h = (h>=this.vH)?this.vH:h;

var h1 = this.iHeight-this.y-this.vH;

h1 = (h1>0)?h1:0;

ctx.drawImage(this.img, 0, h1, this.vW, h, 0, this.vH-h, this.vW, h);

if(h<this.vH)

{

h1=this.vH-h;

ctx.drawImage(this.img, 0, this.iHeight-h1, this.vW, h1, 0, 0, this.vW, h1);

}

}

});

Background.ClassName = "Background";

//注册背景类

ClassFactory.regClass(Background.ClassName, Background);

}())

这里需要注意的是，由于背景精灵是在其他所有的精灵下面的，也就是说，实际上背景精灵应该最先进行渲染，这样，渲染其他精灵的时候就会显示在精灵上面，所以，在进行渲染前，需要对绘制的精灵进行排序。这个就类似于 HTML中Div的概念，所有的精灵都有一个zIdx属性，然后根据这个zIdx排序，zIdx数字越小越先渲染，所以在引擎中的xscene.js文件的Scene类中，增加了一个sortRObj方法，在对所有精灵进行渲染前进行排序操作。

//按zIdx排序所有渲染对象，渲染按数字越小越先渲染

sortRObj:function()

{

this.rObjs.sort(function(o1, o2){

return o1.zIdx-o2.zIdx;

})

}

最后，scene的render方法则修改如下：

render:function()

{

var ltns=this.listeners;

//先清除场景，再渲染

this.clear();

//执行渲染前监听器

for(var i=0, len = ltns.length;i<len;i++)

{

ltns[i].enabled&&ltns[i].onBeforeRender(this);

}

//排序

this.sortRObj();

this.renderRObj();

//执行渲染后监听器

for(var i=0;i<len;i++)

{

ltns[i].enabled&&ltns[i].onAfterRender(this);

}

}

好了，这里关于向下滚动的背景就完成了，本游戏中只创建了一层背景，如果需要的话，还可以创建多层背景，只要背景移动的速度不同就会形成比较好的视觉效果。创建了背景后，接下来我们就需要在场景中增加主角了。

9.3.2 创建射击类

射击游戏中最主要的几个对象除了背景外，主要包括玩家控制的主机、敌机以及子弹这几类。本质上来说，玩家控制的主机和敌机应归属一类，只是所处的阵营不同，而且玩家可以控制主机，敌机自我控制，所以我们可以设计一个射击类，作为玩家控制主机和敌机的父类。整个UML类如图9-5所示，Shooter是Player和Enemy父类， Player表示玩家，Enemy则是敌机，当然，Shooter本身需要从Sprite中继承。

图9-5 射击类图

作为一个Shooter类，最主要的是需要能够进行子弹的发射，以及处理被子弹击中的效果，我们先来看子弹的处理。实际上，飞机发射子弹无非就是根据需要不断地产生子弹类的精灵对象即可，只不过Player需要通过玩家控制发射子弹，而Enemy根据一定的规则进行发射子弹。不管是哪种方式发射子弹，子弹精灵的产生都是相对某一个Shooter对象而言，并且不同的Shooter发射子弹的位置可能不太一样。所以，根据这个特点，我们在Shooter类中增加一个tags属性，可以称该属性为挂载点，该属性用来存储某个精灵上面的几个区域，这几个区域可以作为与之相关的其他精灵的位置，而且，根据需要，这些挂载点具有不同的用途。图9-6所示显示了一架飞机上不同的挂载点。

图9-6取自于著名的游戏"飞虎战机"，该图显示了一架飞机上的3个挂载点，挂载点1和2用于挂载防护弹，而挂载点3则是飞机发射子弹的区域。这样，实际上该图有四个精灵对象，飞机和3个挂载点，当飞机移动的时候，其他几个精灵的位置就位于这几个挂载点上。

根据需要，一个精灵可以有多个挂载点，注意，挂载点是相对该精灵位置，以图9-7为例子，该精灵上的挂载点坐标为（0，−30），由于canvas的Y坐标向上为负，这里挂载点的坐标方式和canvas一致，这样，假设精灵当前的坐标为（x，y），那么位于挂载点的精灵坐标为（x，y−30）。

图9-6 飞机挂载点

图9-7 挂载点坐标

好了，了解挂载点的概念后，其他的就好说了。我们把飞机发射子弹的位置看成几个挂载点，那么，对于Shooter类来说，假设有一个方法叫做createBullet()方法，用于创建子弹，那么，我们就可以使用以下伪代码创建子弹精灵：

function createBullet()

{

循环飞机所有挂载点，对每一个挂载点做如下处理：

1：创建子弹精灵

2：根据该挂载点坐标设置子弹位置

}

这样，就完成了 Shooter 类子弹的发射，接下来，我们同样需要使用状态机来管理飞机的几个状态。对于状态类的设计，可以设计得很粗也可以足够细，这可以具体根据需要定夺。这里，我们就涉及两个状态类，一个称为自由移动状态，另一个就是死亡状态。飞机正常情况下是处于自由移动状态，当然，对于玩家来说，需要通过外部设备控制飞机。当飞机的hp降低为0的时候，飞机就处于死亡状态。

另外一个需要处理的，就是碰撞检测问题，碰撞检测是个比较耗费时的操作，对于射击类型的游戏来说，使用像素检测根本就不在考虑之列。相比来说，使用包围盒比较"廉价"，当然，使用圆形包围盒不太理想，误差较大，所以一般来说兼顾效率和精确度，可以采用下面两种方式。

（1）采用凸多边形包围盒子。通常情况下，我们都可以给一个精灵创建一个形状类似的凸多边形来包围这个精灵，如图9-8所示，采用凸多边形包围算法。

采用这种方式的优势是比较精确，缺点在于比较耗时，而且对于凹形的不好处理，需要把对象拆分成几个凸多边形处理。

（2）采用AABB包围盒。这是非常廉价的检测手段，但精确度不高，为了解决这个问题，通常我们把一个精灵采用多个AABB包围盒的方式处理，比如，对于图9-8的飞机，可以采用如图9-9所示的方式。

图9-8 使用凸多边形包围盒

图9-9 使用多个AABB包围盒

对于图9-9所示的精灵来说，一共使用了5个AABB包围盒。在进行碰撞检测的时候，可以先检测最外围的包围盒，如果连最外面的包围盒都没碰撞，则不用检测，否则，在循环检测其他4个小的包围盒，如果有一个碰撞到则表示已经产生碰撞。采用这种方式可以在效率和精确度之间取得一个比较好的平衡，所以，本例中采用AABB包围盒进行检测。

对于Shooter来说，需要提供一个onCollide()事件，当该Shooter被子弹或者其他Shooter碰撞后，该事件被触发，执行相应的逻辑，通常情况下，该事件的处理伪代码如下：

function onCollide()

{

Shooter对象hp减少

如果hp<0 则对象状态转为死亡

}

根据以上的分析，最后Shooter实现如下：

/*

* Shooter 类

*/

(function(){

//从引擎的Sprite继承

Shooter = Sprite.extend({

init:function(param)

{

this._super(name);

param = PubUtil.merge(param, {"animName":"em0", "hp":100, "bSpeed":4});

//基本配置参数，从游戏配置文件中读取

this.gCfg = param;

//hp，都知道是什么

this.hp = param.hp;

//子弹速度

this.bSpeed = param.bSpeed;

//绑定的射击点

this.tags = param.tags;

this.tagCurCount = new Array(this.tags.length);

//分组groupID = 1 表示敌人，groupID = 0 表示玩家

this.groupID = 1;

//敌方目标组号

this.targetID = 0;

this.setAnims(ResManager.getAnimationsByName("sprite", param.animName));

this.bClassName = Bullet.ClassName;

this.sCtx = new StateContext(this);

this.regState();

this.sCtx.changeState("free");

var hw = (param.bBox&&param.bBox[0])||this.w*0.5,

hh = (param.bBox&&param.bBox[1])||this.h*0.5;

this.bBox = new ABBox(this.x, this.y, hw, hh);

//是否进行碰撞检测

this.isCalcCollide = true;

this.zIdx = 10;

},

//注册状态类

regState:function()

{

this.sCtx.regState(new FreeState("free", this.sCtx));

this.sCtx.regState(new DieState("die", this.sCtx));

},

//创建子弹

createBullet:function(tagIdx)

{

//根据game配置创建子弹

var tags = (tagIdx==null)?this.tags:this.tags[tagIdx];

for(var i = 0;i<tags.length;i++)

{

var tag=tags[i],

bcfg = ShootGame.cfg.sDef[tag[2]],

param = {"os":this, "animName":bcfg.animName, "attack":bcfg.attack,

"targetID":this.targetID, "dx":0, "dy":this.dy+this.bSpeed};

this.tagCurCount[i] = this.tagCurCount[i]||0;

//如果可以发射

if(this.tagCurCount[i]++==tag[4])

{

this.tagCurCount[i]=0;

//调用场景类创建子弹的方法

this.owner.cbFn[tag[3]].call(this.owner, this.x+tag[0], this.y+tag[1],

this.bClassName, param);

}

}

},

//重写update

update:function()

{

//更新包围盒坐标

this.bBox.x = this.x;

this.bBox.y = this.y;

this.sCtx.update();

//超出屏幕下端则删除

if(this.y>0)

{

this.offScreenRemove();

}

},

//被子弹击中会触发事件

onCollide:function(bullet)

{

this.hp-=bullet.attack;

if(this.hp<=0)

{

this.sCtx.changeState("die");

}

}

});

Shooter.ClassName = "Shooter";

//注册Shooter类

ClassFactory.regClass(Shooter.ClassName, Shooter);

//定义状态类，由子类实现

FreeState = State.extend({

});

//定义死亡状态类

DieState = State.extend({

enter:function()

{

var o=this.ctx.owner;

//创建爆破效果

o.owner.createBoom(o.x, o.y);

o.owner.removeRObj(o);

}

});

}())

这里，有两个属性需要注意，一个是groupID，另一个是targetID，这两个ID表示分组号，用来区别自身和敌机，groupID表示自身所在分组编号，targetID用于标识敌机分组。这两个编号非常重要，后面在进行碰撞的时候需要用到。

另外，在createBullet创建子弹方法中，使用了场景类的方法，根据整个游戏的配置来创建子弹，整个游戏除了资源可以配置以外，还有其他，诸如游戏的等级、分数、子弹的攻击力、精灵的hp都可以在配置文件中定义。后面的章节中，我们会详细介绍关于使用配置文件的定义方式，这里暂时只需要了解该方法用于创建子弹就可以了。另外一个需要注意的地方就是在Die状态中，同样使用了场景类的createBoom方法用于创建爆炸效果，当精灵死亡的时候可以创建爆炸效果的精灵。

Shooter类定义完毕之后，接下来我们来实现Player，即玩家类。

9.3.3 创建玩家类

根据前面的分析，Player类需要从Shooter类继承就可以了。当然，还需要做一些调整，比如需要设置Player的groupID以及targetID，Player的free状态的移动需要由玩家控制，以及我们可以给Player添加一些特殊的效果，如防护罩等。

Player类的实现如下：

/*

* Player 类

*/

(function(){

//从引擎的Sprite继承

Player = Shooter.extend({

init:function(param)

{

this._super(param);

this.life = param.life;

this.groupID = 0;

this.targetID = 1;

this.updateState();

//防护层

this.shield = null;

this.sCtx.changeState("free");

this.zIdx = 18;

},

//更新自由移动状态

updateState:function()

{

var freeState=this.sCtx.getState("free");

var dState = this.sCtx.getState("die");

freeState.update = function()

{

var player=this.ctx.owner;

var isKeyPress = false;

if(Key.pressed(Key.W))

{

player.up();

isKeyPress = true;

}

if(Key.pressed(Key.S))

{

player.down();

isKeyPress = true;

}

if(Key.pressed(Key.A))

{

player.left();

isKeyPress = true;

}

if(Key.pressed(Key.D))

{

player.right();

isKeyPress = true;

}

if(Key.pressed(Key.J))

{

player.createBullet();

}

if(!Key.pressed(Key.J))

{

player.tagCurCount[0]=player.tags[0][4];

}

if(!isKeyPress)

{

player.setCAnim("def");

player.animsCtrl.isCycle = true;

}

//检测是否碰撞到敌机

var robj = player.owner.rObjs;

for(var i=0;i<robj.length;i++)

{

if(player.isCalcCollide&&robj[i].isCalcCollide&&(robj[i].targetID==

player.groupID||robj[i].groupID==2))

{

//检测是否碰撞

if(player.bBox.isCollide(robj[i].bBox))

{

player.onCollide(robj[i]);

break;

}

}

}

}

//修改死亡状态

dState.enter = function()

{

var o=this.ctx.owner;

if(--o.life>0)

{

o.isVisible=false;

o.owner.createBoom(o.x, o.y, function(){

//创建防护层

o.createShield();

o.sCtx.changeState("free");

});

}

else

{

o.owner.isOver=true;

}

}

},

left:function()

{

if(this.getCurrentAnim().name!=="left")this.setCAnim("left");

this.animsCtrl.isCycle = false;

if(this.x>this.w*0.5)

{

this.move(-this.dx, 0);

}

},

right:function()

{

if(this.getCurrentAnim().name!=="right")this.setCAnim("right");

this.animsCtrl.isCycle = false;

if(this.x<(this.owner.w-this.w*0.5))

{

this.move(this.dx, 0);

}

},

up:function()

{

if(this.y>this.h*0.5)

{

this.move(0,-this.dy);

}

},

down:function()

{

if(this.y<(this.owner.h-this.h*0.5))

{

this.move(0, this.dy);

}

},

createShield:function(param)

{

param=PubUtil.merge(param,{"os":this,"bBox":[60, 60]});

if(this.shield==null)

{

this.shield=this.owner.createRObj(Shield.ClassName, [param]);

}

},

onCollide:function(obj)

{

//如果是奖励

if(obj.groupID==2)

{

this.owner.removeRObj(obj);

this.createShield({"autoClear":false});

}

else

{

this.sCtx.changeState("die");

}

}

});

Player.ClassName = "Player";

//注册Player类

ClassFactory.regClass(Player.ClassName, Player);

}())

在Player中，增加了life、shiled等几个属性，分别描述生命数和防护罩，同时重写了构造函数。在构造函数中设置groupID和targetID分别为0和1，表示Player分组为0，敌机分组为1，设置成和Enemy类的分组相对，在进行碰撞检测的时候可以依据这两个值进行处理。

Player中的updateState方法中更改了free和die状态类中的行为，在Player处于free状态的时候，用户可以通过W、S、A、D进行方向控制，使用J键控制子弹的发射。同时，这里采用AABB盒方法对所有的敌机进行了碰撞检测，注意，这里只是检测是否碰撞了敌机，并没有检测碰撞子弹的方法，对于子弹的碰撞的检测在子弹类中进行处理。在进行检测和敌机是否碰撞的时候，需要循环该场景中所有的精灵对象，当然，首先可以排除一些不必要的精灵，比如， isCalcCollide标记为false的，精灵的targetID和Player的groupID是不同的，这样就可以省去很多时间。一旦检测到和敌机的碰撞，则触发一个onCollide事件，Player对象复写了onCollide方法，在该方法中将Player对象的状态转换为死亡状态。

updateState方法的最后修改了Player死亡状态的行为，这里可以根据逻辑需要更改。此处的逻辑是，当飞机的生命降为0的时候，设置场景的isOver为true，这时，场景就会转换为结束场景，如果生命数大于0则隐藏飞机，表示Player死亡，然后创建一个防护层，表示飞机复活，飞机复活的时候有防护层保护，当然，有时间限制，最后把Player状态转换为free状态。

接下来，我们看看防护层的实现，防护层的实现其实比较容易，当然也可以非常复杂。一旦防护层打开后，有两种方式处理它的消失，一种方式是通过时间控制，比如当Player死亡后复活的防护层，另一种方式就是当飞机接到防护层的奖励时，防护层会一直存在，当碰到子弹或者敌机时，防护层的hp会减少，当减少为0时，防护层消失。根据这个逻辑，我们实现一个防护层类如下：

/*

* Shield 类

*/

(function(){

//从引擎的Sprite继承

Shield = Sprite.extend({

init:function(param)

{

this._super();

param = PubUtil.merge(param, {"autoClear":true, "lifeTime":1000, "os":null,

"animName":"shield", "hp":100, "targetID":1, "groupID":0});

this.targetID = param.os.targetID;

this.groupID = param.os.groupID;

this.hp = param.hp;

this.os = param.os;

this.lifeTime = param.lifeTime;

this.autoClear = param.autoClear;

this.setAnims(ResManager.getAnimationsByName("sprite", param.animName));

this.animsCtrl.isCycle = false;

this.os.isCalcCollide = false;

var hw = (param.bBox&&param.bBox[0])||this.w*0.5,

hh = (param.bBox&&param.bBox[1])||this.h*0.5;

this.bBox = new ABBox(this.x, this.y, hw, hh);

this.zIdx = this.os.zIdx-1;

this.isCalcCollide = true;

},

//重写update

update:function()

{

this.moveTo(this.os.x, this.os.y);

this.bBox.x = this.x;

this.bBox.y = this.y;

//最后一帧开始计算消失效果

if(this.animsCtrl.isLastFrame()&&!this.isStartClear)

{

this.isStartClear=true;

this.sTime = FrameState.currTime;

this.sHp = this.hp;

this.os.isVisible = true;

}

if(this.isStartClear)

{

//计算ahpha

this.rAlpha = (this.autoClear) ? (1-(FrameState.currTime-this.sTime)/this.

lifeTime):this.sHp/this.hp;

if((!this.autoClear&&this.sHp<0) || (this.autoClear&&(FrameState.currTime-

this.sTime>this.lifeTime)))

{

this.owner.removeRObj(this);

this.os.isCalcCollide = true;

this.os.shield = null;

}

}

},

//重写render

render:function(ctx)

{

ctx.globalCompositeOperation="lighter";

ctx.globalAlpha = this.rAlpha;

this._super(ctx);

},

onCollide:function(bullet)

{

if(!this.autoClear&&this.isStartClear)

{

this.sHp-=bullet.attack;

}

}

});

Shield.ClassName = "Shield";

//注册Shield类

ClassFactory.regClass(Shield.ClassName, Shield);

}())

在Shield类中有这样几个比较重要的属性。

（1）os属性，表示该防护层是属于哪个精灵对象的，一旦属于某个精灵对象，则该防护层的groupID和targetID与os相同。

（2）autoClear属性，表示该防护层是否自动消失，为true则表示该防护层在一定时间内会自动消失，否则，该防护层的hp降到0时，自动消失。

（3）lifeTime属性，表示该防护层自动消失的时间。

最后，我们复写了update方法和render方法，在update方法中，我们会根据autoClear属性决定该防护层如何消失。当防护层的动画显示到最后一帧的时候开始进行消失处理，同时根据当前的时间或者hp设置canvas全局的Alpha属性，修改防护层的透明度，当防护层消失的时候，该防护层的透明度降低为0。

正如前面提到的，Shield 的产生有两种方式，一种是当飞机死亡的时候，另一种是接收到奖励的时候。现在，我们实现一个简单的奖励类，当敌机被玩家击落的时候，就会产生相应的奖励，该奖励精灵的行为四处移动，当碰到窗口的时候会反弹回去，这个效果有点类似于打砖块游戏中的小球。相信对于读者来说应该是个比较容易的实现，以下是奖励类的实现：

/*

* Reward 奖励类

*/

(function(){

//从引擎的Sprite继承

Reward = Sprite.extend({

init:function(param)

{

this._super();

this.setAnims(ResManager.getAnimationsByName("sprite", param.animName));

this.groupID = 2;

var hw = (param.bBox&&param.bBox[0])||this.w*0.5,

hh = (param.bBox&&param.bBox[1])||this.h*0.5;

this.dx = this.dy = 1.5;

this.bBox = new ABBox(this.x, this.y, hw, hh);

//是否进行碰撞检测

this.isCalcCollide = true;

this.zIdx = 16;

},

//重写update

update:function()

{

var w=this.owner.w,

h = this.owner.h;

//到达边界改变速度方向

if(this.x<this.w*0.5||this.x>w-this.w*0.5)

{

this.dx = -this.dx;

};

if(this.y<this.h*0.5||this.y>h-this.h*0.5)

{

this.dy=-this.dy;

};

//调用父类方法

this._super();

}

});

Reward.ClassName = "Reward";

//注册Reward类

ClassFactory.regClass(Reward.ClassName, Reward);

}())

到此为止，Player类已经全部实现，接下来实现Enemy类。

9.3.4 创建敌机类

Enemy类同Player类一样，需要从Shooter类继承，相对Player来说，Enemy的实现根据逻辑的不同可简单，可复杂，因为Enemy不像Player需要手动控制，而是需要电脑来进行控制。

这里，我们来创建两种敌人，一种是普通敌人，另一种是Boss，先来看看普通敌人的实现。对于普通敌人来说，我们给它的逻辑就是在自由状态下发射子弹，这个比较容易实现，代码如下：

//从引擎的Sprite继承

Enemy = Shooter.extend({

init:function(param)

{

this._super(param);

this.score = param.score;

this.sCtx.regState(new FreeState1("free", this.sCtx));

this.sCtx.regState(new DieState1("die", this.sCtx));

this.sCtx.changeState("free");

},

update:function()

{

this._super();

if(this.getCurrentAnim().name=="hurt"){

if(--this.hCount==0)

{

this.setCAnim("def");

this.hCount = 5;

}

}

},

//被子弹击中事件

onCollide:function(bullet)

{

if(this.getAnim("hurt")!=null)

{

this.setCAnim("hurt");

this.hCount = 5;

}

this._super(bullet);

}

});

在Enemy类中，增加了一个score属性，表示该敌机的分数，一旦该敌机死亡，则给游戏增加相应的分数。同时我们复写了update方法以及onCollide方法，在onCollide方法中，我们改变了敌机目前显示的动画帧，表示该敌机正受到攻击，该状态会持续5帧后显示正常状态。另外，在构造函数中重新注册了一个 FreeState1 的状态对象，和一个 DieState1 的状态对象， FreeState1表示该对象表示敌机自由移动时的行为，FreeState1实现如下：

//定义自由状态类1

var FreeState1 = State.extend({

update:function()

{

var e=this.ctx.owner;

//发射子弹

e.createBullet();

//移动

e.moveStep();

}

});

这个状态类比较简单，表示的逻辑就是不断发射子弹以及移动。

DieState1状态定义了敌机的死亡状态，当敌机死亡时候，需要给游戏加上相应的分数，同时有部分敌机被击落时还会掉落奖励，以下是DieState1的实例。

//定义死亡状态1

var DieState1 = DieState.extend({

enter:function()

{

var o=this.ctx.owner;

//加分

if(o.groupID == 1)

{

ShootGame.cfg.score+=o.score;

}

var ox = o.x, oy = o.y;

//创建爆破效果

o.owner.createBoom(o.x, o.y, function(){

//死亡后奖励

if(o.gCfg.rw!=null)

{

varrw=o.owner.createRObj(Reward.ClassName,[ShootGame.cfg.sDef[o.gCfg.rw]]);

rw.moveTo(ox, oy);

}

});

o.owner.removeRObj(o);

}

});

这样，我们就实现了一个最简单的Enemy类。

接下来，我们看看Boss类的实现。对于Boss类，我们给定的逻辑是，Boss开始的时候自由向下移动，移动到一定的坐标，然后开始左右移动，同时发射子弹。因此在实现的时候，我们需要给Boss两个自由状态类，一个是普通的移动，另一个是移动到指定坐标，开始左右移动发射子弹。

Boss类可以直接从Enemy类继承，实现如下：

//Boss类

Boss = Enemy.extend({

init:function(param)

{

this._super(param);

this.name = "Boss";

this.sCtx.regState(new FreeState2("free1", this.sCtx));

this.sCtx.regState(new DieState2("die", this.sCtx));

var freeState = this.sCtx.getState("free");

freeState.change = function()

{

var e=this.ctx.owner;

if(e.y>180)

{

this.ctx.changeState("free1");

}

}

freeState.update = function()

{

var e=this.ctx.owner;

e.moveStep();

}

this.sCtx.changeState("free");

}

});

同时，我们必须定义一个FreeState2类，表示Boss的左右移动，并发射子弹的状态类：

//定义自由状态类2

var FreeState2 = FreeState1.extend({

enter:function()

{

var e=this.ctx.owner;

e.dy = 0;

e.dx = -1;

this._super();

},

update:function()

{

var e=this.ctx.owner;

this._super();

var hw = e.w*0.5;

//左右移动

if(e.x<=hw||e.x>e.owner.w-hw)

{

e.dx=-e.dx;

}

}

});

另外，还需要定义一个DieState2类，当Boss死亡的时候，需要设置当前关卡已经结束标记，该DieState2的实现如下。

//定义死亡状态2

var DieState2 = DieState1.extend({

enter:function()

{

var o=this.ctx.owner;

this._super();

o.owner.isOver = true;

}

});

这样，关于Enemy的实现就已经完成，正如前面所说的，只要你有足够的想象力和时间，你完全可以给Enemy定义更多的状态类以实现更为复杂的行为，思想有多远，Enemy就能有多复杂。

好了，到这里，主角已基本完成，还差一个重要的子弹类，接下来，我们看看子弹类的实现。

9.3.5 创建子弹类

子弹类可以直接从 Sprite 类继承，和普通的 Sprite 精灵不同的地方在于，子弹类需要在update的时候，检测是否有和其他的精灵类，比如Enemy以及Player发生碰撞，一旦发生碰撞，则需要触发Shooter类的onCollide方法。

子弹类的实现代码如下：

/*

* Bullet 类

*/

(function(){

//从引擎的Sprite继承

Bullet = Sprite.extend({

init:function(param)

{

this._super();

param = PubUtil.merge(param, {"os":null, "animName":"b3", "attack":100, "targetID":1}); this.attack = param.attack;

this.targetID = param.targetID;

this.os = param.os;

this.setAnims(ResManager.getAnimationsByName("sprite", param.animName));

this.bBox = new ABBox(this.x, this.y, this.w*0.5, this.h*0.5);

this.zIdx = 20;

},

//重写update

update:function()

{

this._super();

//targets

var robj = this.owner.rObjs;

for(var i=0;i<robj.length;i++)

{

if(this!==robj[i]&&this.os!==robj[i]&&robj[i].isCalcCollide&&robj[i].

groupID==this.targetID)

{

//检测是否碰撞

if(this.bBox.isCollide(robj[i].bBox))

{

robj[i].onCollide(this);

this.owner.removeRObj(this);

return;

}

}

}

}

});

Bullet.ClassName = "Bullet";

//注册Bullet类

ClassFactory.regClass(Bullet.ClassName, Bullet);

}())

同前面 Player 类检测类似，我们在进一步进行碰撞检测时可以先排除部分不符要求的精灵，比如isCalcCollide标记为false的，子弹的targetID和精灵的groupID不同的。

需要注意的是，这个精灵类只是表示一种单一的子弹类，如果要实现一些弹幕效果，比如环形弹幕，我们可以创建多个子弹的实例，只是不同子弹的轨迹不同罢了，在运行场景类的完整实现中，我们给出了一个环形弹幕的实现。

好了，运行场景中几种基本的类都实现了，接下来的工作，我们就需要知道在何时何地创建各种不同的敌人，让我们的主角消灭它们。这时候，我们可以完全使用配置的方式来管理游戏运行的资源，接下来，我们看看游戏配置的实现。

9.3.6 游戏配置实现

很多时候把游戏中需要的一些数据采用配置文件的形式来存储，一方面可以防止游戏中硬编码的情况发生，另一方面可以让配置数据可以更加灵活地控制游戏的逻辑，做到极致的情况是使用数据来驱动游戏逻辑，当然，这并不是一件非常容易的事情。

在游戏运行机制的章节中，我们使用配置文件描述整个游戏的资源，还记得吧？都是采用json 格式文件进行描述，当时，我们使用了两种配置文件，一个用来描述整个游戏中使用的所有资源列表，比如以下的片段：

"image":

[

{"name":"bg1","src":"imgs/bg1.jpg"},

{"name":"shield", "src":"imgs/shield.png"}

],

"frame":

[

{"name":"sprite","src":"data/sprite_frames.json"}

],

其中，image片断描述的是所有使用到的图片资源，frame片断描述的是整个游戏中使用到的精灵帧动画资源。比如以下片断就描述了一个精灵帧动画资源，详细的含义可以参考游戏运行机制中的加载游戏资源部分：

{

"player0":{

"type":1,

"img":"player",

"rc":[1, 10, 64, 64],

"animations":{

"def":[0, 1],

"left":[2, 5],

"right":[6, 9]

}

}

}

这两个文件只是描述了整个游戏中需要使用的资源，接下来，我们需要一个配置文件用来描述整个游戏的其他信息，比如当前玩家的数量、当前的分数、关卡信息等。

我们需要另外定义一种类型的文件描述游戏逻辑相关的配置，当然，也是使用JSON格式，然后，需要把这个配置文件在资源文件中定义，资源配置文件片断就增加了一种类型的资源，看起来有点像以下结构：

"image":

[

{"name":"bg1","src":"imgs/bg1.jpg"},

{"name":"shield", "src":"imgs/shield.png"}

],

"frame":

[

{"name":"sprite","src":"data/sprite_frames.json"}

],

"gCfg":

[

{"name":"gf0","src":"data/gamecfg.json"}

]

以上粗体字部分就是我们新增加的资源类型，我们把它的类型命名为gCfg，其中包含一个名字叫"gf0"的配置文件，该配置文件在data/gamecfg.json中详细描述。既然新增加了一种类型的资源，所以在xengine引擎中的ResManager类中需要新定义一种类型，本质上来说，实际上就是加载一个JSON格式的文件而已，具体实现如下：

//游戏配置资源

var _gCfgRes = win.GCfgRes = {

//加载资源

load:function(name, url, loadedFN)

{

varobj={"type":"gCfg","name":name,"src":url,"data":null,"isLoaded":false};

ResUtil.loadFile(url, null, function(data){

obj.isLoaded = true;

obj.data = data;

loadedFN&&loadedFN();

});

return obj;

}

};

_gCfgRes.ClassName = "gCfg";

最后，需要在ResManager中进行注册：

_resMan.regResType(_gCfgRes.ClassName, _gCfgRes);

现在，配置文件相关的类已经可以加载了。接下来，我们看看data/gamecfg.json如何进行定义。

首先，我们可以在gamecfg.json文件中定义几个简单的属性：

{

"bgH":1369,

"bgScrollSpeed":0.7,

"level":0,

"score":0

}

这几个相关属性的解释如下。

• bgH：背景滚动的总长度，可任意设置，当然至少大于视口长度。

• bgScrollSpeed：背景滚动速度。

• level：当前关卡。

• score：当前游戏的总分数。

然后，我们可以在游戏配置中定义我们需要的精灵对象。注意，这里定义的精灵对象和之前在sprite_frames.json定义的不太一样，sprite_frames.json中只是定义精灵的动画帧，和游戏逻辑没有任何关系，而在游戏配置中定义的精灵是和游戏逻辑相关的，比如我们在游戏配置中定义了一个子弹对象和一个主角对象：

"b3": {

"animName":"b3",

"attack":10

},

"player":{

"animName":"player0",

"life":3,

"tags":[[0, -38, "b3", "cb0", 8]],

"bBox":[17, 24],

"bSpeed":-14,

"hp":1

}

先来看看子弹b3的定义。

• animName：描述该对象所对应的精灵动画帧的对象名称，注意该名称必须在 sprite_frames.json已经定义，这样我们就可以通过这个名称任意更改精灵的外形了。

• attack：描述子弹的攻击力。

接着看看player对象的详细定义。

• animName：同b3 一样。

• life：定义玩家的生命数。

• tags：定义飞机所有的挂载点，前面我们解释过挂载点的概念，这里我们把挂载点当做一个飞机的发射区域。现在，详细看看这个挂载点的定义，一个挂载点包含以下数据：

[0, -38, "b3", "cb0", 8]]

其中，前面两个参数 0 和−38 表示的是该挂载点相对于飞机的坐标位置，"b3"表示的是飞机发射子弹的时候，子弹精灵的对象名称，也就是前面定义的b3，"cb0"描述的是该发射区域发射子弹的函数名称，子弹的发射有可能是单发，也有可能是霰弹，我们把子弹的发射方式通过函数名称的形式进行配置，然后在游戏运行场景中定义一个函数集合，通过这个名称就可以进行相关方法的调用。举个例子，假设我们在游戏运行场景中定义了一个函数集合如下：

fns = {

"cb0:" function(){ 这是每次发射一发子弹的函数}，

"cb1": function(){ 这是每次发射霰弹弹的函数}，

...

}

这样，[0, −38, "b3", "cb0", 8]表示的就是在飞机（0，−38）这个区域发射"b3"类型的精灵子弹，每次发射一发子弹，最后一个8是发射频率，表示每8帧发射一次。这样配置了之后，就可以在任意一种飞机上挂载任意的发射点，并控制发射行为。

• bBox：描述该对象的包围盒。

• bSpeed：描述子弹发射速度。

• hp：血量。

接下来，看一个描述敌机的配置：

"em1":{

"animName":"em1",

"speed":0.5,

"tags":[[-27, -3, "b0", "cb0", 40], [27, -3, "b0", "cb0", 40]],

"bBox":[18, 32],

"hp":150,

"score":500,

"rw":"rw0"

}

和player不同的属性解释如下。

• speed：飞行的速度。

• score：该机被击落时贡献的分数。

• rw：该机被击落时贡献的奖励精灵名称，当然该精灵也必须配置。

所有的精灵对象定义完毕之后，我们看看如何定义关卡。在飞机游戏中，随着游戏的进行，画面中会出现不同的敌人来攻击玩家，敌机的出现一种方式是可以随机的出现，另一种方式是在特定的场合出现。第一种方式比较容易，我们可以随着时间的推演，在随机的地点产生随机的敌人，第二种方式就需要进行配置了，我们来看看以下简单的配置片段：

"enemys":

[

{

"dist":10,

"es":

[

[60,-60,"em0","Enemy"],

[340, -60, "em0", "Enemy"]

]

}

]

以上代码片段各个属性描述的含义如下。

• dist：当前屏幕向下滚动的距离。

• es：当屏幕滚动到dist距离的时候，产生的敌人集合，对于这个敌人集合，采用数组的方式进行描述，比如：

[60, -60, "em0", "Enemy"],

表示的是在屏幕坐标（60，−60）的位置产生一个类型为em0的敌人，em0同样必须在前面精灵配置中定义好，该敌人是Enemy类的一个实例。

根据这个片段，我们就可以在不同的地方设置不同的敌人了。

到此，描述游戏逻辑相关的配置基本上已经设计完毕。

最后，我们给出整个游戏的配置文件代码如下：

{

"bgH":1369,

"bgScrollSpeed":0.7,

"level":0,

"score":0,

"sDef":{

"b0":{

"animName":"b0",

"attack":10

},

"tb0":{

"animName":"tb0",

"attack":10

},

"lb0":{

"animName":"lb0",

"attack":10

},

"b2":{

"animName":"b2",

"attack":10

},

"b3":{

"animName":"b3",

"attack":10

},

"b4":{

"animName":"b4",

"attack":10

},

"b1":{

"animName":"b1",

"attack":10

},

"b6":{

"animName":"b6",

"attack":10

},

"rw0":{

"animName":"rw0"

},

"player":{

"animName":"player0",

"life":3,

"tags":[[0, -38, "b3", "cb0", 8]],

"bBox":[17, 24],

"bSpeed":-14,

"hp":1

},

"em0":{

"animName":"em0",

"tags":[[0, 34, "b0", "cb0", 70]],

"speed":1.5,

"bBox":[20, 25],

"hp":10,

"score":100

},

"em1":{

"animName":"em1",

"speed":0.5,

"tags":[[-27, -3, "b0", "cb0", 40], [27, -3, "b0", "cb0", 40]],

"bBox":[18, 32],

"hp":150,

"score":500,

"rw":"rw0"

},

"em2":{

"animName":"em2",

"bBox":[47, 47],

"tags":[[0, 0, "b6", "cb1", 80]],

"hp":200,

"score":1000

},

"boss":{

"animName":"boss0",

"bBox":[96, 69],

"tags":[[0, 0, "b1", "cb1", 50], [-140, 0, "b0", "cb0", 60], [140, 0, "b0",

"cb0", 60], [0, 120, "lb0", "cb0", 40]],

"hp":1000,

"score":10000

}

},

"lev":[

{

"enemys":

[

{

"dist":10,

"es":

[

[60,-60,"em0","Enemy"],

[340, -60, "em0", "Enemy"]

]

},

{

"dist":60,

"es":

[

[200,-60,"em0","Enemy"],

[160, -100, "em0", "Enemy"],

[240, -100, "em0", "Enemy"],

[120, -140, "em0","Enemy"],

[280, -140, "em0", "Enemy"]

]

},

{

"dist":140,

"es":

[

[100,-60,"em1","Enemy"],

[300, -60, "em1", "Enemy"]

]

},

{

"dist":200,

"es":

[

[200,-60,"em2","Enemy"]

]

},

{

"dist":650,

"es":

[

[200,-60,"boss","Boss"]

]

}

]

}

]

}

9.3.7 运行场景实现

整个游戏逻辑相关的配置已经完成，场景的主角定义也已经全部定义好，接下来，我们完成整个游戏运行场景的实现工作，运行场景的完整代码如下：

/*

* GamingScene 类

*/

(function(){

//从引擎的Scene继承

GamingSC = Scene.extend({

init:function(x, y, name)

{

this._super({"x":x, "y":y, "w":400, "h":600, "name":name});

this.player = null;

this.bg = null;

this.lifeTxt = null;

this.scoreTxt = null;

this.createPlayerFromCfg();

this.initBg();

this.initFn();

this.initUI();

this.initLtn();

},

//初始化UI

initUI:function()

{

this.lifeTxt=$("<divid='pLifeTxt'style='text-align:left;position:absolute;

font-size:26px; color:red;height:40px;width:130px;left:10px'>

Life:0</div>");

this.scoreTxt = $("<div id='pScTxt' style='text-align:left;position:absolute;

font-size:26px; color:red;height:40px;width:130px;left:260px'>

Score:0</div>");

this.holder.append(this.lifeTxt);

this.holder.append(this.scoreTxt);

},

//初始化主角

createPlayerFromCfg:function()

{

var pCfg=ShootGame.cfg.sDef.player;

this.player = this.createRObj(Player.ClassName, [pCfg]);

this.player.moveTo(200, 550);

this.player.dx = this.player.dy = 3;

}

//初始化背景

initBg:function()

{

var img=ResManager.getResByName("image","bg1");

this.bg = this.createRObj(Background.ClassName, [400, 600, ShootGame.cfg. bgH,img.hEle]);

this.bg.dy = ShootGame.cfg.bgScrollSpeed;

},

//初始化监听器

initLtn:function()

{

var ltn=new SceneEventListener();

this.currEneItem = null;

var self = this;

ltn.onAfterRender = function()

{

//获取当前关卡配置创建敌人

var lv = ShootGame.cfg.lev[ShootGame.cfg.level];

if(!self.currEneItem&&lv.enemys.length>0)

{

self.currEneItem=lv.enemys.shift();

}

var e = self.currEneItem;

if(e&&self.bg.scrollY>e.dist)

{

var es=e.es;

for(var i=0;i<es.length;i++)

{

self.createEnemy(es[i][0], es[i][1],

es[i][3], ShootGame.cfg.sDef[es[i][2]]);

}

self.currEneItem = null;

}

//更新UI

self.lifeTxt.text("Life:"+self.player.life);

self.scoreTxt.text("Score:"+ShootGame.cfg.score);

}

this.addListener(ltn);

},

initFn:function()

{

this.cbFn={

"cb0":this.createBullet,

"cb1":this.createCirBullet

};

},

//创建子弹

createBullet:function(x, y, bClass, param)

{

var bul=this.createRObj(bClass, [param]);

bul.moveTo(x, y);

bul.dx = param.dx;

bul.dy = param.dy;

},

//创建环形子弹

createCirBullet:function(x, y, bClass, param)

{

var r=3;

for(var i=0;i<360;i+=30)

{

var dy=r*Math.sin(MathUtil.deg2rad(i)),

dx = r*Math.cos(MathUtil.deg2rad(i));

param.dx = dx;

param.dy = dy;

this.createBullet(x, y, bClass, param);

}

},

//创建爆炸精灵

createBoom:function(x, y, callBack)

{

var obj=this.createRObj(Sprite.ClassName);

obj.setAnims(ResManager.getAnimationsByName("sprite", "boom0"));

obj.moveTo(x, y);

//重写update方法

obj.update = function()

{

//最后一帧播放完毕后死亡

if(this.animsCtrl.isLastFrame())

{

this.owner.removeRObj(this);

callBack&&callBack();

}

}

},

//根据配置数据创建敌人

createEnemy:function(x, y, eClass, cfgData)

{

var ene=this.createRObj(eClass, [cfgData]);

ene.moveTo(x, y);

ene.dy = ShootGame.cfg.bgScrollSpeed+(cfgData.speed||0.1);

return ene;

}

});

GamingSC.ClassName = "GamingSC";

//注册运行场景类

ClassFactory.regClass(GamingSC.ClassName, GamingSC);

}())

最终运行场景类的效果如图9-10所示，美术不是笔者的强项，所以，整个游戏中使用到的图片素材取自于网络。其中，飞机的图片素材来自于一款叫做Project Blaze Zero 的飞行射击游戏，该游戏采用了Construct2游戏引擎开发，Construct2游戏引擎也是一款基于HTML5的2D游戏引擎，它可以让你在不用编码的情况下开发出一款令人满意的小游戏。

运行场景的实现已经完成，最后，我们来实现结束场景。

图9-10 运行场景图

9.4 创建结束场景

游戏的结束场景，这里设计得相对简单，在整个游戏结束后，会显示玩家的分数，以及最后返回开始界面重新进行游戏，效果如图9-11所示。

图9-11 结束场景图

完整的代码实现如下：

/*

* EndScene 类

*/

(function(){

//从引擎的Scene继承

EndSC = Scene.extend({

init:function(x, y, name)

{

this._super({"x":x,"y":y,"w":400,"h":600,"name":name});

this.isRetry = false;

this.initUI();

},

//初始化UI

initUI:function()

{

this.initStartUI();

},

//初始化StartGame UI

initStartUI:function()

{

var self=this;

var sDiv = $("<div id='sDiv' align='center' style='cursor:pointer;position:

absolute;border-bottom:1px solid white;left:100px;top:300px;color:white;

font-size:18px; line-height:18px;height:18px;width:200px;'>Return To Title

</div>")

sDiv.click(function(){

self.isRetry = true;

})

this.holder.append("<div align='center' style='position:absolute;left:100px;

top:240px;color:Red; font-size:36px;line-height:36px;height:38px;width:200px;'>

Game Over</div>");

this.holder.append("<div align='center' style='position:absolute;left:100px;

top:180px;color:Red; font-size:36px;line-height:36px;height:38px;width:200px;'>

Score:"+ShootGame.cfg.score+"</div>");

this.holder.append(sDiv);

}

});

EndSC.ClassName = "EndSC";

//注册结束场景类

ClassFactory.regClass(EndSC.ClassName, EndSC);

}())

到此，整个射击游戏的3个场景已经全部完成，也结束了我们第一次实战之旅，希望这次的实战之旅能让你感到兴奋。

9.5 小结

飞行射击类游戏可以说是曾经风靡了一个时代，还记得笔者小时候在街机厅里排队玩"火凤凰"躲枪林弹雨的情景。作为一种STG游戏来说，重要的是游戏的打击爽快感，相信随着经验的丰富，读者可以做出一款更为精彩的游戏。

第10章 编程实战之《你画我猜》

前面我们学习到了关于Node.js网络通信相关的知识，对于canvas的使用应该到了一个炉火纯青的地步了吧。本章将结合canvas以及Node.js框架完成网络版的《你画我猜》的小游戏，相信，这个小游戏完成后，大家对于网络游戏会有一个基本的认识。

下面就来开始《你画我猜》的游戏之旅。

10.1 了解游戏逻辑

《你画我猜》游戏的逻辑也就是游戏规则相对来说比较简单，基本采用以下的流程。

（1）登录进入《你画我猜》服务器，进入后会看到标有 1，2，3，……等号码的房间，每个房间最多可以有7个人同时玩，所以，如果房间显示满员，那么你就最好去选择其他的房间。

（2）进入房间后等待其他用户加入，一般都会是7个人，如果大家都准备好之后，房间的创建者选择开始，即开始一轮新游戏。

（3）开始游戏后，进入游戏主界面，游戏一般分为3个回合，每一回合中，都会由游戏参与者轮流在画板上画画，画画的内容由游戏提示，可以是一个名词、一个动作或者是职业等。

当然，只由当前绘制的人才能够使用画笔绘画，其他人只能看到画板上的内容，然后根据画板上的内容猜测究竟画的是什么，猜对的人会获取相应的分数，当然，第一个猜对的人会得到比较高的分数。在游戏的过程中，如果题目过于复杂，系统还会给出相应提示，每次猜测都有时间限制，达到时间后如果没有任何人猜对，则此轮结束，由下一个人开始绘画，这样轮流绘画，直到3个回合全部结束。

（4）3个回合结束后，统计每个人的分数。

（5）游戏结束后用户可以继续参与或者离开游戏。

以上就是《你画我猜》游戏的主体流程，这里，我们做一下简化，目前只有一个房间，所有的用户都登录到这个房间开始游戏，由第一个人选择开始按钮，开始一轮新的游戏。

游戏的逻辑部分先解说到这里，下面来一步步完成这个逻辑。

首先，从UI界面开始。事实上，对于UI界面，我们可以先做原型，不需要多绚丽的样式，完成整个逻辑功能后再调整。

10.2 UI界面设计

首先，我们来设计一个画板的主页面，这个UI的界面和canvas基础中的《你画我猜》的UI基本类似，只是增加了一个登录层，在登录层中，用户可以输入用户名以及服务器地址，然后选择登录，这里省略了密码的认证过程。这里，可以创建一个 client 文件夹，里面存放所有客户端的文件，首先建立一个index.html主页，实现如下：

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

<title>你画我猜</title>

<link href="images/CSS.css" rel="stylesheet" type="text/css">

</head>

<body>

<!--登录层-->

<div id="loginDiv" class="dlbj">

<div>服务器：<input id="host" type="text" name="textfield" value="127.0.0.1">

<br>

</div>

<div>用户名：<input id="tUser" type="text" name="textfield"></div>

<div class="dlfwq" id="btnCon"><img src="images/dlfwq.png" border="0"></div>

</div>

<!--主容器层-->

<div id="main" class="main">

<div>

<div class="tx"></div>

<div class="wz">

<div id="dround"></div>

<!--显示问题区域-->

<span id="question"></span>

</div>

<!--显示时间-->

<div id="qTime" class="time">60</div>

<div class="tc">离开房间<img src="images/edit_undo.png" align="absmiddle"></div>

</div>

<div class="qc"></div>

<div>

<!--绘图主层-->

<div class="hbbox">

<!--绘图区域-->

<div id="hb" class="hb">

<!--效果层-->

<div id="effect"></div>

<canvas id="paintArea" width="525" height="370" style="display:none" ></canvas>

</div>

<!--用户显示区域-->

<div id="userArea">

</div>

</div>

<!--工具选项区-->

<div class="hbr">

<div id="operDiv" style="display:none">

<!--颜色选取区-->

<div id="ys" class="ys"></div>

<!--画笔大小选择区-->

<div id="bc" class="bc"></div>

<!--定义橡皮擦工具-->

<div id="ssx" class="tb">

<div id="btnRub" class="tb2"><img src="images/01945.png"></div>

<div id="btnClear" class="tb3"><img src="images/053753321.gif"></div>

</div>

</div>

<!--消息发布主层-->

<div id="msgArea" class="msgArea">

</div>

<div class="bd">

<input type="button" id="btnSendMsg" value="发送"><input type="text" size="13"

id="txtMsg">

</div>

</div>

</div>

</div>

<!--定义消息层-->

<div id="msg">

</div>

</body>

<script src="../js/jquery.js" charset="utf-8"></script>

<script>

//检测是否支持HTML5

function isSupportHTML5()

{

return (typeof(Worker) !=="undefined");

}

//初始化

$(document).ready(function(){

if(!isSupportHTML5())

{

alert("您的浏览器不支持HTML5，请更换浏览器!");

}

else

{

$("#btnCon").click(function(){

$("#loginDiv").hide();

$("#main").show();

});

}

})

</script>

</html>

这里有两个主要的层，一个是loginDiv，这个是登录层；另一个main层是玩家操作层。在loginDiv 中，用户可以输入登录服务器以及用户姓名，然后选择登录，目前的功能仅限于当用户点击登录按钮后隐藏登录层然后显示操作层。

接下来让我们首先完成登录连接服务器的功能。

10.3 登录到服务器的实现

这里，我们继续使用Node.js作为游戏服务端开发平台，客户端使用socketio.js框架，两者通过WebSocket进行通信，对于Node.js开发平台不太熟悉的读者可以参考前面的相关章节。

这里双方进行通信的方式如图10-1所示。

基本的通信模式如图 10-1 所示，每个浏览器客户端连接到游戏服务器上，游戏服务器通过连接的Websocket客户端创建一个服务客户端，由每个服务客户端完成对相应客户端的处理，游戏服务器将完成游戏逻辑的处理。

图10-1 客户端和服务端通信模型

在以上模型中，只使用了一种游戏逻辑服务器，基本是一个最简单的游戏模型，稍复杂的服务器可能还会分为登录服务器、大厅服务器、游戏逻辑服务器、代理服务器以及数据服务器等。当然，这又是一个很大的话题，对于本例的使用，这种模型足以应付。

接下来我们看看服务端游戏服务器的实现。

10.3.1 创建简单的游戏服务器对象

就目前来说，我们只需要实现用户登录的功能即可，以后我们可以逐渐完善相应的功能。

为了便于管理，我们把所有服务端的实现文件放到一个server文件夹中，在server文件夹中创建一个server.js文件，该文件是一个最简单的服务器实现：

(function(){

//导入客户端模块

var cli = require("./client");

var http=require("http");

//定义服务端

var Server = function(){

//绑定的io对象

this.io = null;

//记录所有的客户端

this.clients =[];

};

Server.prototype = {

listen:function(port)

{

var srv=http.createServer(function(req, rep){});

this.io = require("socket.io").listen(srv);

//设置日志级别

this.io.set('log level', 1);

srv.listen(port);

this.bindEvent();

},

//绑定事件

bindEvent:function()

{

var self=this;

//注册连接事件

this.io.sockets.on("connection", function(socket){self.doConnect(socket)});

},

doConnect:function(socket)

{

this.addClient(socket);

},

//添加一个socket客户端

addClient:function(socket)

{

console.log("add new client:"+socket.id);

this.clients.push(cli.newClient(this, socket));

},

//移出一个socket客户端

removeClientByID:function(sID)

{

var idx = -1;

for(var i =0;i<this.clients.length;i++)

{

if(this.clients[i].so.id==sID)

{

idx=i;

break;

}

}

if(idx!=-1){

this.clients.splice(idx, 1);

}

},

//判断用户是否存在

isUserExists:function(client)

{

for(var i=0;i<this.clients.length;i++)

{

if(client!=this.clients[i]&&this.clients[i].user.uname==client.user.uname)

{

return true;

}

}

return false;

},

//广播消息

broadcastMsg:function(msg)

{

this.io.sockets.send(msg);

},

//广播事件

broadcastEvent:function(eventName, data)

{

this.io.sockets.emit(eventName, data);

}

}

//启动服务

new Server().listen(9000);

}())

对前面的网络游戏基础章节了解清楚的读者应该不会对以上代码感到陌生，这里，我们首先创建了导入所需的模块，一个是http模块，另一个是自定义的服务客户端client模块。Server类中提供了几个常用的方法，listen()方法用于监听端口，在io端口上绑定了connection事件，在 connection 事件中，一旦有新的连接到来，服务器就会创建一个客户端实例，这个客户端实例其实就是保持和连接远程客户端进行通信，为了便于管理所有的客户端。在Server中有一个clients 属性，保存所有正确连接到服务器的客户端，另外，为了能够保证服务器向所有的客户端群发消息以及事件，Server提供了broadcastMsg()以及broadcastEvent()方法，很多时候都会使用到群发功能，比如发送提示信息、游戏进行的及时数据等。

基本的框架已经搭好，服务器类中导入了client模块，接下来看看client模块的的具体实现。

10.3.2 创建服务客户端对象

为了避免和远程连接的客户端弄混淆，这里使用服务客户端来表示在服务器上和远程客户端保持连接的类，这个类的主要职责就是用来处理和远程客户端的通信逻辑。

在server文件夹中创建一个client.js文件，实现该类的功能：

(function(){

//定义客户端操作对象

var Client = function(server, socket){

//绑定服务器

this.srv = server;

//绑定的socket

this.so = socket;

//用户信息

this.user = {"uname":null, "level":0, "score":0};

//绑定事件

this.bindEvent();

};

//定义客户端各操作函数

Client.prototype = {

toString:function()

{

return"[uname:"+this.user.uname+", sid:"+this.so.id+"]";

},

//绑定事件

bindEvent:function()

{

if(this.so)

{

var self=this;

//注册登录事件

this.so.on("login", function(data, fn){self.doLogin(data, fn);});

}

},

//处理用户登录

doLogin:function(data, fn)

{

if(data)

{

this.user.uname=data.uname;

var isExists = this.srv.isUserExists(this);

//通知客户端;

if(!isExists)

{

fn(0);

}

else

{

fn(1);

this.so.disconnect("unauthorized");

this.srv.removeClientByID(this.so.id);

}

}

},

}

//输出模块

exports.newClient = function(server, socket)

{

return new Client(server, socket);

}

}())

一旦服务器有新的远程客户端连接上来，则会新建一个 Client 与之对应，Client 的构造方法中调用bindEvent方法绑定一系列的事件处理函数，注意bindEvent中的以下代码：

//注册登录事件

this.so.on("login", function(data, fn){self.doLogin(data, fn);});

该代码表示在socket上绑定了一个自定义的login事件，其原型是：

socket.on(action, function)

其中action表示事件名称，function表示处理该事件的函数，如果客户端有数据传递过来，则function的第一个参数就是该数据，一旦在服务端的socket中注册了事件处理函数，那么，客户端socket可以使用如下方式向服务端发送事件：

socket.emit(action, data, function)

其中action为事件的名称，data为数据，function为回调函数，需要注意的是客户端和服务端定义的事件名称必须相同，在接下来的小节中我们将会看到客户端的用法。

所以，就通信的双方来说，只要客户端有一个socket.emit("×××事件")，对应的服务端的socket就必须有一个socket.on("×××事件")来处理该事件，时刻记住这一点就可以了×××表示用户自己定义的事件名称。

就目前来说，需要实现的功能是完成登录操作，所以，暂时只处理了登录的操作，在后面的实现中，我们会逐渐添加需要的功能。

在注册登录事件中，使用了doLogin方法处理登录事件，当前登录的逻辑是：检测用户名，如果有同名用户存在则断开连接，同时从服务器中删除该客户端以避免不必要的资源浪费，否则就调用回调函数。当调用这个回调函数的时候，该回调函数会把参数传递给远程客户端，我们就可以利用这个参数做一些事情了，比如在doLogin中，分别调用了fn(0)和fn(1)，这样就可以把0或者1传递给远程客户端了。现在的问题是远程客户端如何连接服务器并且传递客户数据，接下来实现远程客户端的连接事件。

10.3.3 创建客户端对象

服务器对象基本搭建起来，现在来看看客户端对象的实现，客户端的职责就是发送玩家操作的数据到服务器，然后由服务器分发管理这些数据。

同样，为了便于管理，我们创建一个js文件夹，里面存放第三方的框架以及客户端相应的实现，现在js文件夹中建立client.js文件，实现如下：

(function(){

//定义客户端

var Client = {

//客户端socket

so:null,

isFirstConnect:true,

//当前用户

user:{"uname":null}

};

//连接服务器

Client.connect = function(host, port){

var p = port||80, self = this;

if(this.so==null)

{

this.so=io.connect("http://"+host+":"+p,{'reconnect':false});

if(this.so)

{

//绑定连接socket事件

this.so.on("connect", function(){self.doConnect();});

//绑定错误处理

this.so.on("error", function(data){this.so = null;alert("服务器连接失败!");});

}

}

else

{

this.so.socket.reconnect();

}

};

//登录

Client.login = function(callback)

{

//通知服务端login事件

this.so.emit("login", {"uname":this.user.uname}, function(data){

callback(data);

});

};

//连接事件

Client.doConnect = function()

{

//如果是第一次连接，注册事件

if(this.isFirstConnect)

{

this.isFirstConnect=false;

this.bindEvent();

}

else

{

//重新连接

this.so.socket.reconnect();

}

};

//绑定客户端socket事件

Client.bindEvent = function()

{

};

window.Client = Client;

}())

Client是一个单例类中包含，目前包含客户端的socket对象、当前用户对象。当用户调用connect方法时，将会创建一个 socket 对象，接着绑定 connect 事件，在 connect 事件中调用doConnect方法用来绑定客户端的各种其他自定义事件，Client中的login方法用以下方式向服务器发送自定义事件通知：

this.so.emit("login", {"uname":this.user.uname}, function(data){

callback(data);

});

一旦客户端发送了login事件，与之对应的服务客户端socket的服务端处理函数就会处理该login事件。

到此，客户端对象创建完毕，接下来，我们可以完成index.html主页中的相关功能了，在index.html中的脚本部分，可以修改如下：

<script src="../js/jquery.js" charset="utf-8"></script>

<script src="../js/client.js" charset="utf-8"></script>

<script src="../js/socket.io.js" charset="utf-8"></script>

<script>

//检测是否支持HTML5

function isSupportHTML5()

{

return (typeof(Worker) !=="undefined");

}

//初始化

$(document).ready(function(){

if(!isSupportHTML5())

{

alert("您的浏览器不支持HTML5,请更换浏览器!");

}

else

{

$("#btnCon").click(function(){

var host = $("#host").val(),

user = $("#tUser").val();

Client.connect(host, 9000);

Client.user.uname = user;

Client.login(function(data){

//登录成功

if(data==1)

{

alert("该用户已经存在，请换用户登录!");

}

else

{

$("#loginDiv").hide();

$("#main").show();

}

});

});

}

})

</script>

基本上登录部分就已经处理完毕，接下来的工作就是处理游戏逻辑部分了。首先，我们来创建画板对象，所有的游戏逻辑都是在一个简单的画板对象中完成的，先来看看画板对象的实现。

10.4 画板对象实现

事实上在前面canvas基础章节的案例中，我们基本完成了画板对象的功能，包括画笔的选择、颜色的选择、绘制图像，等等，在这里就不重复讲解单机版的实现了，关于单机版的实现读者可以参考2.7节的案例。这里我们可以直接使用前面的Paint类，剩下需要做的工作就是完成多个用户联机的功能。

在完成联机游戏的过程中我们一步步来完成需要的功能，对于任何一个软件来说，我们可以先列出该软件所需功能的优先级，优先完成最主要的功能。对于本游戏来说，最主要的功能就是联机绘画过程，即当一个人绘制图像的时候，其他人必须都能实时看到，在此基础之上，我们再添加其他的游戏逻辑。

所以，在之前的画板功能之上，我们先来添加联机绘图功能。

联机绘图实现

图10-2 广播玩家数据

实现联机绘图的过程不是非常复杂，简单地说，其实就是当玩家在使用画笔在画板上进行绘图工作的时候，把当前这个玩家的绘图的数据传递到服务器，然后由服务器把该数据广播到其他玩家，其他玩家的画笔将根据这些数据自动在画板上进行绘制。图10-2 展示了数据广播的过程。

需要注意的是，在进行广播的时候，并不是直接使用前面Server类中的broadcastEvent方法，因为该方法是把数据广播给所有的客户端，我们需要的是把数据广播到其他玩家，而不包括当前绘制的玩家，所以，可以使用socketio.js中的socket.broadcast.emit方法，该方法的原型如下：

socket.broadcast.emit(action, data, function)

参数的含义和socket.emit一样，所不同的是，该方法广播的时候只会把数据广播到除自己之外的其他客户端。

到此为止，已经足够我们完成联机绘图了，接下来的工作是，我们需要在什么时候、什么地方广播什么样的数据了。

经过分析，我们发现在以下事件中需要广播数据。

（1）开始进行绘画工作。

（2）整个绘画过程。

（3）当画笔颜色、大小发生改变时。

为了能够实现广播数据，我们先在Client类中发送这些自定义事件到服务客户端，然后由服务客户端进行广播。注意这个Client指的不是服务客户端的Client，而是在js文件夹中的Client类。

所以，我们在Client类中增加几个方法用于发送这些事件，实现如下：

//发送开始画画事件

Client.emitStartDraw = function(data)

{

this.so.emit("startDraw", data);

};

//发送画画事件

Client.emitDrawing = function(data)

{

this.so.emit("drawing", data);

}

//发送画板更新事件

Client.emitPaintUpdate = function(data)

{

this.so.emit("paintUpdate", data);

}

方法已经有了，接下来就需要分析在什么地方使用这些方法，根据前面的事件分析，当然是触发这些事件的时候调用了。

在之前Paint类中，我们曾经自定义过onStartDraw、onDrawing以及onPaintUpdate等事件，这3个自定义事件正好是发送数据的最佳时机，所以，Paint中这3个方法修改如下：

//开始画画事件

onStartDraw:function(data){

this.status = 0;

//开始路径

this.ctx.beginPath();

this.ctx.moveTo(data.x, data.y);

//发送开始画画事件

if(Client.isOperUser())

{

Client.emitStartDraw(data);

}

},

//画画事件

onDrawing:function(data)

{

if(this.status==0)

{

this.status=1;

}

this.ctx.lineTo(data.x, data.y);

this.ctx.stroke();

if(Client.isOperUser())

{

//发送画画事件

Client.emitDrawing(data);

}

},

//画板更新事件，当画板的参数比如画笔颜色、大小改变时触发

onPaintUpdate:function(data)

{

var w=data.width||this.bWidth,

c = data.color||this.bColor;

var param = {"width":w, "color":c};

this.setBrushWidth(w);

this.setBrushColor(c);

//发送画板更新事件

if(Client.isOperUser())

{

Client.emitPaintUpdate(param);

}

}

这里需要注意的是，只有当前进行绘制的用户才需要进行发送数据，其他用户的画板只需要根据接受到的数据自动绘制即可，所以，在这些发送事件之前调用了Client的isOperUser方法判断是否是当前用户，该方法的实现在接下来的小节中可以看到。

正如前面描述的，既然客户端使用emit发送了事件，服务客户端socket就需要接收这些事件，必须有对应的socket.on("×××事件")进行处理。于是，我们必须在服务客户端中处理这3个事件，修改Client类中的bindEvent方法，注意，是server文件夹下的，代码如下：

//绑定事件

bindEvent:function()

{

if(this.so)

{

var self=this;

//注册登录事件

this.so.on("login", function(data, fn){self.doLogin(data, fn);});

//绑定开始画图事件

this.so.on("startDraw", function(data){self.broadcastStartDraw(data);});

//绑定画图事件

this.so.on("drawing", function(data){self.broadcastDrawing(data);});

//处理画板更新事件

this.so.on("paintUpdate", function(data){self.broadcastPaintUpdate(data);});

}

}

好了，同登录事件一起，现在一共注册了4个事件，当然，我们还必须加上这3个事件的处理方法。需要注意的是"startDraw"、"drawing"和"paintUpdate"这3个事件是由当前玩家触发的，也就是说，在这3个事件的处理过程中，必须由当前玩家向其他玩家做广播，把他的绘图数据传递给其他玩家，于是，我们实现这3个事件的处理如下：

//广播开始画图事件

broadcastStartDraw:function(data)

{

this.so.broadcast.emit("startDraw", data);

},

//广播画图事件

broadcastDrawing:function(data)

{

this.so.broadcast.emit("drawing", data);

},

//广播画板更新事件

broadcastPaintUpdate:function(data)

{

this.so.broadcast.emit("paintUpdate", data);

},

好了，此时，由当前玩家服务客户端进行了广播事件，那么其他玩家的远程客户端就必须处理这些事件，处理的方式，就是根据当前玩家广播的数据来进行自动绘图。需要注意的是当前玩家和其他玩家的身份可能随时互换，所以，没有必要再另外写一个Client类来处理这些事件，同样地，我们把处理服务客户端的广播事件在Client类的中处理，这个Client同样是客户端的，随时注意一点，通信的双方的一方使用socket.emit事件，另一方就必须使用socket.on绑定该事件进行处理。

好了，接下来，我们在js文件夹下的client.js文件中修改Client类的bindEvent事件如下：

Client.bindEvent = function()

{

var self=this;

//注册开始画画事件

this.so.on("startDraw", function(data){self.startDraw(data)});

//注册画画事件

this.so.on("drawing", function(data){self.drawing(data)});

//处理画板更新事件

this.so.on("paintUpdate", function(data){self.paintUpdate(data);});

}

在Client类中，必须响应当前玩家发送的数据，然后进行相应的处理，这里的处理就是让画笔能够自动在画板上进行绘画，这就比较好实现了。事实上，我们在设计Paint类的时候，绘图的方式都是采用事件驱动的形式，也就是说只要触发Paint的相应事件，就能够在画板上实现绘制工作，于是这3个事件的处理方法就比较简单了，实现如下：

//开始画画

Client.startDraw = function(data)

{

Painter.fire("onStartDraw", data);

};

//画画事件

Client.drawing = function(data)

{

Painter.fire("onDrawing", data);

};

//更新画板事件

Client.paintUpdate = function(data)

{

Painter.fire("onPaintUpdate", data);

};

事实上，我们只是简单调用了Painter的事件触发，由Painter类完成绘制工作。

初写网络游戏逻辑这块可能会比较容易搞糊涂，因为完成一个逻辑功能需要客户端和服务端同时进行，不过相信当你熟悉一个功能的实现后，其他的方式就比较容易做了。

到此为止，联机绘图的功能也就是最重要的功能已经实现了，接下来，看看如何实现游戏的其他功能。

10.5 游戏数据定义

在进行其他工作之前，我们需要注意到，在游戏的进行过程当中，游戏服务器需要实时地把游戏当前的状态广播给所有的玩家，以便于所有的玩家需要知道当前游戏进行到了什么程度。比如当前的时间信息、当前的正在绘画的玩家、当前进行的局数等，所以有必要在游戏中先设计出来，当然，数据文件可以是任意格式，这里我们同样使用JSON格式。

根据游戏的规则，我们可以把游戏中需要的数据先列举出来。

（1）游戏的回合数。

（2）每一轮的进行时间。

（3）游戏中的问题。

（4）当前绘图用户。

（5）当前游戏给出的题目。

（6）目前游戏进行的时间。

（7）当前游戏进行到第几回合。

（8）当前游戏的状态。

好了，我们可以分析一下，这些数据当中，前面3项可以看作是游戏的配置数据，这些数据在游戏的进行过程中是固定不变的，后面其他的数据都是在游戏的进行过程当中会实时变化的，所以根据以上分析，我们在server文件夹中创建data.js文件，作为游戏服务器需要的数据对象，具体实现如下：

(function(){

//配置数据

var _gameConfig={

//每一盘的时间

"time":60,

//回合数

"round":2,

//题目类型

"qtype":["成语", "职业", "动物", "动漫人物"],

"score":[3, 2, 1],

//题目数据

"qdata":[

["天人合一", "五体投地", "亡羊补牢", "掩耳盗铃", "一寸光阴一寸金"],

["教师", "学生", "程序员", "歌手", "驯兽师", "模特", "演员"],

["老鼠", "老虎", "蟑螂", "蚂蚁", "老鹰", "蟒蛇", "兔子"],

["蜡笔小新", "奥特曼", "蜡笔小新", "葫芦娃", "超人", "钢铁侠", "孙悟空", "铁臂阿童木"]

]

}

//当前游戏数据信息

var _gameInfo ={

//当前画图的用户索引

"uIdx":-1,

//当前画图的用户

"user":null,

//当前问题

"question":{"type":null, "data":null},

//当前游戏进行时间

"time":0,

//当前进行的回合数

"round":0,

"status":0, //游戏状态，0 表示未开始，1 表示已开始，2 表示结束一轮问题

"rUserCount":0, //记录答对游戏人数

"rUser":null//第一个答对的用户

}

//输出模块

exports.gameData = function()

{

return{

"cfg":_gameConfig,

"info":_gameInfo

};

}

}())

游戏的配置数据可以根据需要进行配置，这样进行了配置之后，我们需要在Server类中稍做修改，首先需要导入data文件，然后在Server类中新加两个属性分别表示配置信息以及当前的游戏信息，在启动服务器的时候读取数据文件的数据。到目前为止，Server类的实现如下所示：

(function(){

//导入客户端模块

var cli = require("./client"),

data = require("./data");

var http=require("http");

//定义服务端

var Server = function(){

//绑定的io对象

this.io = null;

//记录所有的客户端

this.clients =[];

//当前游戏信息

this.info = null;

//当前游戏配置信息

this.cfg = null;

};

Server.prototype = {

listen:function(port)

{

var srv=http.createServer(function(req, rep){});

this.io = require("socket.io").listen(srv);

//设置日志级别

this.io.set('log level', 1);

srv.listen(port);

this.bindEvent();

//获取游戏信息

this.info = data.gameData().info;

this.cfg = data.gameData().cfg;

},

//绑定事件

bindEvent:function()

{

var self=this;

//注册连接事件

this.io.sockets.on("connection", function(socket){self.doConnect(socket)});

},

doConnect:function(socket)

{

this.addClient(socket);

},

//添加一个socket客户端

addClient:function(socket)

{

console.log("add new client:"+socket.id);

this.clients.push(cli.newClient(this, socket));

},

//移出一个socket客户端

removeClientByID:function(sID)

{

var idx=-1;

for(var i =0;i<this.clients.length;i++)

{

if(this.clients[i].so.id==sID)

{

idx=i;

break;

}

}

if(idx!=-1){

this.clients.splice(idx, 1);

}

},

//判断用户是否存在

isUserExists:function(client)

{

for(var i=0;i<this.clients.length;i++)

{

if(client!=this.clients[i]&&this.clients[i].user.uname==client.user.uname)

{

return true;

}

}

return false;

},

//广播消息

broadcastMsg:function(msg)

{

this.io.sockets.send(msg);

},

//广播事件

broadcastEvent:function(eventName, data)

{

this.io.sockets.emit(eventName, data);

}

}

//启动服务

new Server().listen(9000);

}())

这里，我们用 listen 方法获取游戏数据信息，在游戏的进行过程当中，我们就可以把这些游戏的数据广播到所有客户端了。

接下来，我们一步步实现游戏的其他功能，首先，来实现用户登录后，显示所有玩家的信息。

10.6 显示所有玩家

如图 10-3 所示，登录游戏后，在游戏进行的主界面下方用矩形框框住的地方为用户显示区域，该区域显示所有的用户信息，每个用户登录后会有一个头像。当然，目前我们先用一个图片代替，头像的上方显示姓名，下方显示这个用户获得的分数。在游戏没有开始前，第一个用户下方显示"Go"字样，只有第一个用户点击了"Go"后，游戏才开始，当然，这个规则是简化后的。

图10-3 游戏主界面

现在，我们就来实现这个功能。

很明显，当用户登录和退出的时候，我们需要更新用户区域以显示最新的用户信息，当触发这两个事件的时候，服务端都应该实时把用户信息广播给所有的客户端。首先，我们需要在Server类中增加一个方法，能够把当前的玩家信息通知给所有玩家，该方法的实现如下：

//获取所有用户姓名

getAllUser:function()

{

var p=[];

for(var i = 0 ;i<this.clients.length;i++)

{

p.push(this.clients[i].user);

}

return p;

},

//通知客户端更新客户信息

updateUserInfo:function()

{

//如果没有一个客户端

var users = this.getAllUser();

if(users.length != 0)

{

this.broadcastEvent("updateUserInfo", users);

};

}

这里，使用了服务端的广播事件方法，把所有用户信息广播到客户端，当用户登录或者退出的时候，都必须调用此方法进行广播，所以在之前的服务客户端，也就是server文件夹中的client.js文件的Client类的登录和退出时需调用该方法，Client的doLogin方法修改如下：

//处理用户登录

doLogin:function(data, fn)

{

if(data)

{

this.user.uname=data.uname;

var isExists = this.srv.isUserExists(this);

//通知客户端;

if(!isExists)

{

fn(0);

this.srv.updateUserInfo();

}

else

{

fn(1);

this.so.disconnect("unauthorized");

this.srv.removeClientByID(this.so.id);

}

}

}

这样，用户正确登录后，就会接收到更新用户事件，所以客户端接收到该事件后，就必须更新用户区域的用户信息，在客户端Client类的bindEvent方法中增加处理该消息的方法如下：

Client.bindEvent = function()

{

var self=this;

//注册开始画画事件

this.so.on("startDraw", function(data){self.startDraw(data)});

//注册画画事件

this.so.on("drawing", function(data){self.drawing(data)});

//处理画板更新事件

this.so.on("paintUpdate", function(data){self.paintUpdate(data);});

//处理游戏用户更新事件

this.so.on("updateUserInfo", function(data){self.doUpdateUserInfo(data);});

}

这样，客户端就可以响应服务端的事件并更新所有用户信息了，更新所有用户的方法doUpdateUserInfo()在最终的代码中会给出，这里只需讲解客户端和服务端是如何通信解决问题的。

到现在，可以显示所有玩家了，玩家绘制图像的时候，也可以让其他玩家看到，接下来，我们实现发送消息部分。

10.7 发送文本消息

事实上，有了前面服务端和客户端通信的例子，发送消息就显得比较容易了，无非就是客户端使用socket.send()方法，在服务客户端注册一个接收消息事件，然后把这个消息再广播给其他的玩家。需要注意的是，如图10-4所示，消息显示区域将会显示所有消息，消息的来源有两个，一个是来自其他客户端广播的消息，另一个是来自服务器广播的消息，不管是哪方发送的消息，对于接收端来说是一样的。

一旦客户端接收到消息后，就需要在消息栏显示所有的消息。

图10-4 游戏主界面

这部分的实现相对来说比较容易，相信读者自己应该可以完成，给自己一点信心和力量，加油！偷懒的读者可以看最后的完整代码。

接下来的工作就是交给游戏服务器来控制游戏的运行逻辑了。

10.8 游戏逻辑实现

从第一个用户点击"Go"开始，游戏逻辑的运行就会完全由游戏服务器来控制，在游戏的进行过程当中，游戏服务器应该处理以下逻辑。

（1）开始一个新的回合。

（2）开始一个新的问题。

（3）检测用户是否回答正确。

（4）发游戏提示信息。

（5）给答对用户相应奖励。

（6）结束一个回合。

（7）结束游戏。

这里每个逻辑环节的实现都涉及服务端和客户端的通信，但实际上都和前面所讲解的类似，这里，我们给出解决每个逻辑的实现思路，在最终代码中，读者可以参考完整的实现。

（1）根据游戏数据的回合配制信息，服务器可以循环回合数，如果当前正在进行的回合大于配置的回合数最大值，则游戏终止，在每一回合中，服务器将循环所有参与的玩家，轮流产生一道题目，让玩家绘图。

（2）在回合开始后，服务器将轮流给每个参与玩家出题，题目的产生将从配置信息中随机产生，并把题目信息发送给当前绘画的玩家，同时，服务器开始计时，把当前游戏进行的时间广播给所有玩家，玩家可以根据画板上绘制的图案以及系统提示信息，输入问题的答案。在玩家回答题目的过程中，如果所有用户都答对题目或者时间到，则该题目结束，服务器公布正确答案，轮到下一个玩家绘画，如果已经是最后一个玩家则开始一个新的回合。

（3）根据用户发出的消息和服务器产生的本轮题目进行对比，相同则表示答案正确。

（4）在玩家答题的过程中，服务器可以根据当前的答题时间向所有的客户端广播提示信息，提示信息可以根据答案的类型或者字数等设计。

（5）在玩家答题的过程中，服务器判断玩家是否答对题目，如果有玩家给出正确答案，则当前正在绘画的玩家得3分，第一个答对的玩家得2分，其他答对的给1分，这个得分可以进行配置，同时，广播所有客户端每个用户的得分情况。

（6）每一回合进行过程中，当服务器给最后一个玩家的题目结束后，开始新的一个回合。

（7）开始新的一个回合时对比游戏的配置参数，如果大于配置的回合数最大值，则游戏结束。

好了，关于游戏逻辑的实现思路就讲解到这里，最后，给出该游戏的完整代码作为参考，相信大家在认真做好这一个小型的网络游戏后，会有一定的收获。

10.9 最终实现

最终整个游戏的文件夹结构如图10-5所示。

图10-5 《你画我猜》文件结构图

10.9.1 主页面最终实现

以下是游戏主页面index.htm的完整代码实现：

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>

<title>你画我猜</title>

<link href="images/CSS.css" rel="stylesheet" type="text/css">

</head>

<body>

<!--登录层-->

<div id="loginDiv" class="dlbj">

<div>服务器：<input id="host" type="text" name="textfield" value="127.0.0.1">

<br>

</div>

<div>用户名：<input id="tUser" type="text" name="textfield"></div>

<div class="dlfwq" id="btnCon"><img src="images/dlfwq.png" border="0"></div>

</div>

<!--主容器层-->

<div id="main" class="main">

<div>

<div class="tx"></div>

<div class="wz">

<div id="dround"></div>

<!--显示问题区域-->

<span id="question"></span>

</div>

<!--显示时间-->

<div id="qTime" class="time">60</div>

<div class="tc">离开房间<img src="images/edit_undo.png" align="absmiddle"></div>

</div>

<div class="qc"></div>

<div>

<!--绘图主层-->

<div class="hbbox">

<!--绘图区域-->

<div id="hb" class="hb">

<!--效果层-->

<div id="effect"></div>

<canvas id="paintArea" width="525" height="370" style="display:none" ></canvas>

</div>

<!--用户显示区域-->

<div id="userArea">

</div>

</div>

<!--工具选项区-->

<div class="hbr">

<div id="operDiv" style="display:none">

<!--颜色选取区-->

<div id="ys" class="ys"></div>

<!--画笔大小选择区-->

<div id="bc" class="bc"></div>

<!--定义橡皮擦工具-->

<div id="ssx" class="tb">

<div id="btnRub" class="tb2"><img src="images/01945.png"></div>

<div id="btnClear" class="tb3"><img src="images/053753321.gif"></div>

</div>

</div>

<!--消息发布主层-->

<div id="msgArea" class="msgArea">

</div>

<div class="bd">

<input type="button" id="btnSendMsg" value="发送"><input type="text" size="13"

id="txtMsg">

</div>

</div>

</div>

</div>

<!--定义消息层-->

<div id="msg">

</div>

</body>

<script src="../js/jquery.js" charset="utf-8"></script>

<script src="../js/client.js" charset="utf-8"></script>

<script src="../js/painter.js" charset="utf-8"></script>

<script src="../js/socket.io.js" charset="utf-8"></script>

<script>

//检测是否支持HTML5

function isSupportHTML5()

{

return (typeof(Worker) !=="undefined");

}

//初始化

$(document).ready(function(){

if(!isSupportHTML5())

{

alert("您的浏览器不支持HTML5，请更换浏览器!");

}

else

{

$("#btnCon").click(function(){

var host = $("#host").val(),

user = $("#tUser").val();

Client.connect(host, 9000);

Client.user.uname = user;

Client.login(function(data){

//登录成功

if(data==1)

{

alert("该用户已经存在，请换用户登录!");

}

else if(data==2)

{

alert("游戏正在进行中，请稍候登录!");

}

else

{

$("#loginDiv").hide();

$("#main").show();

}

});

});

//绑定发送消息事件

$("#btnSendMsg").click(function(){

var txt = $("#txtMsg").val();

Client.sendMsg(txt);

$("#txtMsg").val("");

});

$("#txtMsg").on("keydown", function(e){

if(e.keyCode==13)

{

var txt=$("#txtMsg").val();

Client.sendMsg(txt);

$("#txtMsg").val("");

}

})

}

})

</script>

</html>

10.9.2 客户端最终实现

js文件夹下客户端client.js文件：

(function(){

//定义客户端

var Client = {

//客户端socket

so:null,

isFirstConnect:true,

//当前用户

user:{"uname":null},

//游戏信息

gameInfo:{"user":null},

};

//连接服务器

Client.connect = function(host, port){

var p = port||80,

self = this;

if(this.so==null)

{

this.so=io.connect("http://"+host+":"+p,{'reconnect':false});

if(this.so)

{

//绑定连接socket事件

this.so.on("connect", function(){self.doConnect();});

//绑定错误处理

this.so.on("error", function(data){this.so = null;alert("服务器连接失败!");});

}

}

else

{

this.so.socket.reconnect();

}

};

//当前用户是否正在操作的用户

Client.isOperUser = function()

{

return this.user.uname==this.gameInfo.user.uname;

}

//登录

Client.login = function(callback)

{

//通知服务端login事件

this.so.emit("login", {"uname":this.user.uname}, function(data){

callback(data);

});

};

//连接事件

Client.doConnect = function()

{

//如果是第一次连接，注册事件

if(this.isFirstConnect)

{

this.isFirstConnect=false;

this.bindEvent();

}

else

{

//重新连接

this.so.socket.reconnect();

}

};

//绑定客户端socket事件

Client.bindEvent = function()

{

var self=this;

//消息事件

this.so.on("message", function(msg){self.doMessage(msg);});

//注册开始画画事件

this.so.on("startDraw", function(data){self.startDraw(data)});

//注册画画事件

this.so.on("drawing", function(data){self.drawing(data)});

//处理画板更新事件

this.so.on("paintUpdate", function(data){self.paintUpdate(data);});

//处理游戏用户更新事件

this.so.on("updateUserInfo", function(data){self.doUpdateUserInfo(data);});

//处理游戏回合准备事件

this.so.on("questionReady", function(data){self.doQuestionReady(data);});

//处理游戏开始事件

this.so.on("startQuestion", function(data){self.doStartQuestion(data);});

//处理游戏回答问题事件

this.so.on("processQuestion", function(data){self.doProcessQuestion(data);});

//提示事件

this.so.on("hint", function(data){self.doHint(data);});

//获取奖励

this.so.on("award", function(data){self.doAward(data);});

//结束问题

this.so.on("endquestion", function(data){self.doEndquestion(data);});

//结束游戏

this.so.on("gameover", function(data){self.doGameOver(data);});

};

//收到消息事件，收到服务器消息触发

Client.doMessage = function(msg)

{

$("#msgArea").append(msg).append("<br/>");

$("#msgArea").scrollTop($("#msgArea")[0].scrollHeight);

};

//根据用户信息更新单个用户的UI

Client.updateUser = function(user, idx)

{

var self=this;

//如果是当前用户，更新当前用户信息

if(user.uname == this.user.uname)

{

this.user=user;

}

//创建用户UI

var px = $("<div id='ulx' class='ulx'></div>");

var ud = $("<div id='u_"+user.uname+"' class='uready'><div id='aw_"+user.uname+"'

class='award'></div></div>"),

glev = $("<div id='uc_"+user.uname+"' class='ugo'></div>");

px.append("<div style='overflow:hidden'>"+user.uname+"</div>").

append(ud). append(glev);

$("#userArea").append(px);

glev.text(user.score);

//如果是第一个登录的用户

if(user.uname==this.user.uname&&idx==0)

{

glev.text("GO!");

//绑定点击事件

glev.on("click", function(){

//设置回调函数处理用户准备好事件

self.so.emit("gameStart", null, function(data){

glev.off("click");

glev.text(user.score);

});

});

}

};

//更新用户信息

Client.doUpdateUserInfo = function(data)

{

var self=this,

users = data;

//更新用户区

$("#userArea").empty();

//更新用户

for(var i=0;i<users.length;i++)

{

self.updateUser(users[i], i);

}

};

//发送消息

Client.sendMsg = function(msg)

{

this.so.send(msg);

};

//发送开始画画事件

Client.emitStartDraw = function(data)

{

this.so.emit("startDraw", data);

};

//发送画画事件

Client.emitDrawing = function(data)

{

this.so.emit("drawing", data);

}

//发送画板更新事件

Client.emitPaintUpdate = function(data)

{

this.so.emit("paintUpdate", data);

}

//开始画画

Client.startDraw = function(data)

{

Painter.fire("onStartDraw", data);

};

//画画事件

Client.drawing = function(data)

{

Painter.fire("onDrawing", data);

};

//更新画板事件

Client.paintUpdate = function(data)

{

Painter.fire("onPaintUpdate", data);

};

//处理游戏回合准备事件

Client.doQuestionReady = function(data)

{

this.ReadyEffect(data);

}

//处理游戏开始事件

Client.doStartQuestion = function(data)

{

var user=data[0].user,

msg = "现在由:"+user.uname+"开始画画";

this.gameInfo = data[0];

//清除画板

Painter.clear();

//恢复用户层样式

$("#ulx div[id^=u_]").each(function(){

$(this).attr("class", "uready");

});

$("#u_"+user.uname).attr("class", "uoper");

$("#operDiv").hide();

//显示效果层

$("#effect").show();

$("#qTime").show();

$("#qTime").text(data[1]);

//显示题目回合

$("#dround").text("第"+this.gameInfo.round+"/"+data[2]+"轮");

//如果是当前操作用户，修改信息栏

if(this.user.uname == user.uname)

{

varm="请按照题目画图,题目是:<spanstyle='color:red'>"+data[0].question.data+

"</span>";

$("#question").html(m);

//显示操作画图层

$("#operDiv").show();

}

else

{

$("#question").text(msg);

}

$("#msgArea").append(msg+"<br/>");

$("#msgArea").scrollTop($("#msgArea")[0].scrollHeight);

}

//显示信息

Client.showMessage = function(msg)

{

var p=$("#hb");

var ww = p.width(), wh = p.height(), ox = p.offset().left, oy = p.offset().top;

var dmsg = $("#msg");

dmsg.text(msg);

dmsg.css("left", ox+(ww-300)*0.5);

dmsg.css("top", oy+(wh-130)*0.5)

$("#msg").fadeIn(100, function(){

$(this).fadeOut(3000);

});

}

//提示

Client.doHint = function(data)

{

if(!this.isOperUser())

{

$("#question").text("提示:"+data);

}

$("#msgArea").append("提示:"+data+"<br/>");

$("#msgArea").scrollTop($("#msgArea")[0].scrollHeight);

}

//获取奖励

Client.doAward = function(data)

{

//更新用户奖励

for(var i=0;i<data.length;i++)

{

var u=data[i].user,

awd = data[i].awd;

//处理动画

$("#ulx div[id=aw_"+u.uname+"]").text("+"+awd+"").attr("class", "awardAnim").

bind("webkitTransitionEnd",function(){

$(this).text("").attr("class", "award");

})

$("#ulx div[id=uc_"+u.uname+"]").text(u.score);

}

}

//结束问题

Client.doEndquestion = function()

{

Painter.clear();

//释放鼠标并隐藏

$("#paintArea").trigger("mouseup").hide();

//显示正确答案

Client.showMessage("正确答案是:"+this.gameInfo.question.data);

}

//结束游戏

Client.doGameOver = function()

{

this.showMessage("Game Over!");

}

//处理游戏回答问题事件

Client.doProcessQuestion = function(data)

{

$("#qTime").text(data.time);

}

//处理ready效果

Client.ReadyEffect = function(data)

{

$("#effect").text(data);

if(data < 0)

{

$("#effect").hide();

//显示画图层

$("#paintArea").show();

}

}

window.Client = Client;

}())

10.9.3 画板最终实现

js文件夹下画板painter.js文件代码如下：

(function(){

//定义画板对象

var Painter = {

//绑定的环境上下文

ctx:null,

//宽度

w:0,

//高度

h:0,

//状态-1表示初始或结束状态，0表示开始画，1表示画画中

status:0,

//当前画笔颜色

bColor:null,

//当前画笔大小

bWidth:null,

//初始化

init:function()

{

var can=$("#paintArea")[0];

this.ctx = can.getContext("2d");

this.w = can.width;

this.h = can.height;

this.setBGColor();

this.setBrushColor();

this.setBrushWidth();

this.ctx.lineCap = "round";

this.ctx.lineJoin = "round";

//初始化画板事件

this.initCanvas();

//初始化画笔颜色

this.initBrush();

//初始化橡皮擦

this.initEraser();

},

//初始化画笔

initBrush:function()

{

varbColor=["#000000","#999999","#FFFFFF","#FF0000","#FF9900","#FFFF00",

"#008000", "#00CCFF", "#0099FF", "#FF33CC", "#CC66FF", "#FFCCCC",

"#6633FF", "#CCFFCC"];

var bDiv = $("#ys"),

self = this;

//产生颜色层

for(var i=0;i<bColor.length;i++)

{

var b = $("<div class='bys'></div>").css("background-color", bColor[i]);

//修改颜色

b.on("click", function(){

//触发更新画板状态事件

self.fire("onPaintUpdate", {"color":$(this).css("background-color")});

});

bDiv.append(b);

}

//绑定画笔大小

var bWidth = [2, 8, 16, 24];

var bcDiv = $("#bc");

for(i = 0;i<bWidth.length;i++)

{

var bw=$("<div class='bwid' data-bidx='"+(i)+"'></div>");

bw.css("background-image", "url(images/bc"+(i+1)+".png)");

//修改画笔大小

bw.on("click", function(){

//触发更新画板状态事件

self.fire("onPaintUpdate", {"width":bWidth[this.getAttribute("data-bidx")]});

});

bcDiv.append(bw);

}

},

//初始化橡皮擦

initEraser:function()

{

var self=this;

//绑定清除屏幕事件

$("#btnClear").click(function(){

self.clear();

});

//擦除

$("#btnRub").click(function(){

self.setBrushColor("white");

self.setBrushWidth(32);

});

},

//设置背景颜色

setBGColor:function(color){

this.ctx.fillStyle = color||"white";

this.ctx.fillRect(0, 0, this.w, this.h);

},

//设置画笔颜色

setBrushColor:function(color)

{

this.bColor=color||"black";

this.ctx.strokeStyle = this.bColor;

},

//设置画笔宽度

setBrushWidth:function(width)

{

this.bWidth=width||1;

this.ctx.lineWidth = this.bWidth;

},

//初始化画板

initCanvas:function()

{

var can=$("#paintArea"),

self = this;

//绑定鼠标按下时间

can.on("mousedown", function(e){

if(!Client.isOperUser())

{

return;

}

e.preventDefault();

this.x = e.offsetX,

this.y = e.offsetY;

self.fire("onStartDraw", {"x":this.x, "y":this.y});

//绑定鼠标移动事件

can.on("mousemove", function(e){

var nx = event.offsetX, ny = event.offsetY;

self.fire("onDrawing", {"x":nx, "y":ny});

this.x = nx;

this.y = ny;

});

//绑定鼠标抬起事件

can.on("mouseup", function(){

//取消鼠标移动事件

can.off("mousemove");

//触发绘画完毕

self.fire("onDrawEnd");

});

})

},

//清除canvas

clear:function()

{

this.ctx.clearRect(0, 0, this.w, this.h);

},

//触发画板事件

fire:function(eventName, param)

{

if(this[eventName])

{

this[eventName](param);

}

},

//开始画画事件

onStartDraw:function(data){

this.status = 0;

//开始路径

this.ctx.beginPath();

this.ctx.moveTo(data.x, data.y);

//发送开始画画事件

if(Client.isOperUser())

{

Client.emitStartDraw(data);

}

},

//画画事件

onDrawing:function(data)

{

if(this.status==0)

{

this.status=1;

}

this.ctx.lineTo(data.x, data.y);

this.ctx.stroke();

if(Client.isOperUser())

{

//发送画画事件

Client.emitDrawing(data);

}

},

//绘画完毕事件

onDrawEnd:function()

{

this.status=-1;

},

//画板更新事件，当画板的参数比如画笔颜色、大小改变时触发

onPaintUpdate:function(data)

{

var w=data.width||this.bWidth, c=data.color||this.bColor;

var param = {"width":w, "color":c};

this.setBrushWidth(w);

this.setBrushColor(c);

//发送画板更新事件

if(Client.isOperUser())

{

Client.emitPaintUpdate(param);

}

}

}

//画板初始化

Painter.init();

window.Painter = Painter;

}())

10.9.4 游戏服务器最终实现

server文件夹下server.js文件代码如下：

(function(){

//导入客户端模块

var cli = require("./client"),

data = require("./data");

var http=require("http");

//定义服务端

var Server = function(){

//绑定的io对象

this.io = null;

//记录所有的客户端

this.clients =[];

//定时器句柄

this.tHand = null;

//当前游戏信息

this.info = null;

//当前游戏配置信息

this.cfg = null;

};

Server.prototype = {

listen:function(port)

{

var srv=http.createServer(function(req, rep){});

this.io = require("socket.io").listen(srv);

//设置日志级别

this.io.set('log level', 1);

srv.listen(port);

this.bindEvent();

//获取游戏信息

this.info = data.gameData().info;

this.cfg = data.gameData().cfg;

},

//绑定事件

bindEvent:function()

{

var self=this;

//注册连接事件

this.io.sockets.on("connection", function(socket){self.doConnect(socket)});

},

doConnect:function(socket)

{

this.addClient(socket);

},

//添加一个socket客户端

addClient:function(socket)

{

console.log("add new client:"+socket.id);

this.clients.push(cli.newClient(this, socket));

},

//移出一个socket客户端

removeClientByID:function(sID)

{

var idx=-1;

for(var i =0;i<this.clients.length;i++)

{

if(this.clients[i].so.id==sID)

{

idx=i;

break;

}

}

if(idx!=-1){

this.clients.splice(idx, 1);

}

//如果所有客户端都退出，则游戏复位

if(this.info.status!=0&&this.clients.length == 0)

{

clearInterval(this.tHand);

this.resetGameInfo();

}

},

//判断用户是否存在

isUserExists:function(client)

{

for(var i=0;i<this.clients.length;i++)

{

if(client!=this.clients[i]&&this.clients[i].user.uname==client.user.uname)

{

return true;

}

}

return false;

},

//获取所有用户姓名

getAllUser:function()

{

var p=[];

for(var i = 0 ;i<this.clients.length;i++)

{

p.push(this.clients[i].user);

}

return p;

},

//广播消息

broadcastMsg:function(msg)

{

this.io.sockets.send(msg);

},

//广播事件

broadcastEvent:function(eventName, data)

{

this.io.sockets.emit(eventName, data);

},

//通知客户端更新客户信息

updateUserInfo:function()

{

//如果没有一个客户端

var users = this.getAllUser();

if(users.length != 0)

{

this.broadcastEvent("updateUserInfo", users);

};

},

//答案是否正确

isRightAnswer:function(ans)

{

return ans==this.info.question.data;

},

//随机产生题目

getRandQuestion:function()

{

var tidx=Math.random()*this.cfg.qtype.length|0,

didx = Math.random()*this.cfg.qdata[tidx].length|0,

type = this.cfg.qtype[tidx],

ques = this.cfg.qdata[tidx][didx];

return {"type":type, "data":ques};

},

//获取下一个可用用户

getNextUser:function()

{

if(this.clients.length!=0)

{

var idx=(++this.info.uIdx)%this.clients.length;

this.info.uIdx = idx;

return this.clients[idx].user;

}

else

{

return null;

}

},

//复位游戏数据

resetGameInfo:function()

{

var info=this.info;

info.time = 0;

info.round = 0;

info.uIdx = -1;

info.user = null;

info.question = {"type":null, "data":null};

info.status = 0;

info.rUserCount = 0;

info.rUser = null;

},

//处理一个问题，开始一个问题时，每秒钟发送

processQuestion:function()

{

if(this.isQuestionOver())

{

//结束问题

this.endQuestion();

}

else

{

this.broadcastEvent("processQuestion",{"time":this.cfg.time-this.info.time});

}

//发出提示信息

if(this.info.time ==8)

{

this.broadcastEvent("hint", this.info.question.data.length+"个字");

}

else if(this.info.time ==16)

{

this.broadcastEvent("hint","类型:"+this.info.question.type);

}

},

//开始游戏回合

startGameRound:function()

{

var self=this;

if(this.info.round==this.cfg.round)

{

this.endGameRound();

return;

}

this.info.status = 1;

this.info.round++;

this.broadcastMsg("--第"+(this.info.round)+"回合开始--");

this.startQuestion();

},

//开始问题

startQuestion:function()

{

var self=this,

info = this.info;

info.time = 0;

info.status = 1;

info.question = this.getRandQuestion();

info.user = this.getNextUser();

info.rUserCount = 0;

info.rUser = null;

this.broadcastEvent("startQuestion", [info, this.cfg.time, this.cfg.round]);

this.doQuestionReady();

},

//游戏回合前准备

doQuestionReady:function()

{

var time=5, self=this;

this.io.sockets.emit("questionReady", time);

this.tHand = setInterval(function(){

if(time < 0)

{

clearInterval(self.tHand);

//开始一个问题

self.tHand = setInterval(function(){

self.info.time++;

self.processQuestion();

}, 1000);

}

else

{

self.io.sockets.emit("questionReady",--time);

}

},1000)

},

//检查是否可以结束一个问题

isQuestionOver:function()

{

returnthis.info.status==1&&(this.isAllRight()||this.info.time==this.cfg.time);

},

endQuestion:function()

{

var self=this;

clearInterval(this.tHand);

this.info.status = 2;

//运行3秒后重新开始一个问题

var t = 3;

this.broadcastEvent("endquestion");

self.tHand = setInterval(function(){

t--;

if(t==0)

{

if(self.info.uIdx!=self.clients.length-1)

{

self.startQuestion();

}

else

{

self.startGameRound();

}

}

}, 1000);

},

//终止游戏回合

endGameRound:function()

{

clearInterval(this.tHand);

this.resetGameInfo();

this.broadcastEvent("gameover", this.getAllUser());

},

//检测是否所有用户都已经答对

isAllRight:function()

{

return this.clients.length-1==this.info.rUserCount;

},

//获取奖励，第一个用户得2分其他的得1分，如果是当前用户则不用，否则，只要有一个用户答对，

//该操作用户得3分

getAward:function(user)

{

var result=0;

//得分用户不是当前用户

if(user.uname !=this.info.user.uname)

{

this.info.rUserCount++;

if(this.info.rUserCount==1)

{

result=2;

}

else

{

result=1;

}

}

return result;

}

}

//启动服务

new Server().listen(9000);

}())

10.9.5 游戏配置最终实现

server文件夹下data.js文件代码如下：

(function(){

//配置数据

var _gameConfig={

//每一盘的时间

"time":60,

//回合数

"round":2,

//题目类型

"qtype":["成语", "职业", "动物", "动漫人物"],

"score":[3, 2, 1],

//题目数据

"qdata":[

["天人合一", "五体投地", "亡羊补牢", "掩耳盗铃", "一寸光阴一寸金"],

["教师", "学生", "程序员", "歌手", "驯兽师", "模特", "演员"],

["老鼠", "老虎", "蟑螂", "蚂蚁", "老鹰", "蟒蛇", "兔子"],

["蜡笔小新", "奥特曼", "蜡笔小新", "葫芦娃", "超人", "钢铁侠", "孙悟空", "铁臂阿童木"]

]

}

//当前游戏数据信息

var _gameInfo ={

//当前画图的用户索引

"uIdx":-1,

//当前画图的用户

"user":null,

//当前问题

"question":{"type":null, "data":null},

//当前游戏进行时间

"time":0,

//当前进行的回合数

"round":0,

"status":0, //游戏状态，0 表示未开始，1 表示已开始，2 表示结束一轮问题

"rUserCount":0,//记录答对游戏人数

"rUser":null//第一个答对的用户

}

//输出模块

exports.gameData = function()

{

return{

"cfg":_gameConfig,

"info":_gameInfo

};

}

}())

10.9.6 服务客户端最终实现

server文件夹下client.js文件代码如下：

(function(){

//定义客户端操作对象

var Client = function(server, socket){

//绑定服务器

this.srv = server;

//绑定的socket

this.so = socket;

//用户信息

this.user = {"uname":null, "level":0, "score":0};

//绑定事件

this.bindEvent();

};

//定义客户端各操作函数

Client.prototype = {

toString:function()

{

return "[uname:"+this.user.uname+", sid:"+this.so.id+"]";

},

//绑定事件

bindEvent:function()

{

if(this.so)

{

var self=this;

//注册断开连接事件

this.so.on("disconnect", function(){self.doDisconnect();});

//注册登录事件

this.so.on("login", function(data, fn){self.doLogin(data, fn);});

//注册准备游戏开始事件

this.so.on("gameStart", function(data, fn){self.doGameStart(data, fn);});

//绑定处理消息事件

this.so.on("message", function(msg){self.doMsg(msg);});

//绑定开始画图事件

this.so.on("startDraw", function(data){self.broadcastStartDraw(data);});

//绑定画图事件

this.so.on("drawing", function(data){self.broadcastDrawing(data);});

//处理画板更新事件

this.so.on("paintUpdate", function(data){self.broadcastPaintUpdate(data);});

//绑定退出房间事件

this.so.on("exit", function(){self.doExit();});

}

},

//处理断开连接

doDisconnect:function()

{

this.srv.removeClientByID(this.so.id);

//通知客户端;

this.srv.updateUserInfo();

},

//处理已经准备好游戏事件

doGameStart:function(data, fn)

{

this.srv.startGameRound();

fn();

},

//处理用户登录

doLogin:function(data, fn)

{

if(data)

{

this.user.uname=data.uname;

var isExists = this.srv.isUserExists(this);

//通知客户端;

if(!isExists)

{

fn(0);

this.srv.updateUserInfo();

}

else

{

var result=1;

if(this.srv.info.status!=0)

{

result=2;

}

fn(result);

this.so.disconnect("unauthorized");

this.srv.removeClientByID(this.so.id);

}

}

},

//处理消息

doMsg:function(msg)

{

var gInfo=this.srv.info, m=msg;

if(gInfo.status==1&&this.srv.isRightAnswer(m))

{

m="*";

//获取用户奖励得分

var award = this.srv.getAward(this.user);

if(award>0)

{

var awd=[];

this.user.score+=award;

awd.push({"user":this.user, "awd":award});

if(this.srv.info.rUserCount == 1)

{

this.srv.info.user.score+=3;

awd.push({"user":this.srv.info.user, "awd":3});

}

this.srv.broadcastEvent("award", awd);

}

}

this.srv.broadcastMsg("用户"+this.user.uname+"说:"+m);

},

//广播开始画图事件

broadcastStartDraw:function(data)

{

this.so.broadcast.emit("startDraw", data);

},

//广播画图事件

broadcastDrawing:function(data)

{

this.so.broadcast.emit("drawing", data);

},

//广播画板更新事件

broadcastPaintUpdate:function(data)

{

this.so.broadcast.emit("paintUpdate", data);

},

//处理离开事件

doExit:function()

{

}

}

//输出模块

exports.newClient = function(server, socket)

{

return new Client(server, socket);

}

}())

10.10 小结

到此，一个完整的《你画我猜》游戏就完成了，虽然是简化版的游戏，但基本包含了网络游戏的基本要素。当然如果要用到生产环境中还有相当大的差距，比如如何解决服务器平衡负载问题、不同用户之间的网络延迟问题、网络数据安全问题等，相信随着读者经验的增多，这些问题都会成为成功路上的铺路石。

第11章 编程实战之《疯狂的小鸟》

前面我们学习了Box2D物理引擎的基本知识，以及如何将Box2D和xengine渲染引擎进行结合。本章将以游戏《愤怒的小鸟》为蓝本，制作一个名叫《疯狂的小鸟》的游戏，游戏的玩法完全类似于《愤怒的小鸟》。这里，以相对完整的一个关卡作为案例进行讲解，对前面所学过的部分知识做一个小结，相信经过本章案例的讲解之后，大家也会做出更多的物理小游戏，顺便补充一句，本章的图片素材全取自于网络。

下面，进行我们的小鸟之旅吧。

11.1 建立游戏主界面

《愤怒的小鸟》是一个典型的关卡式的游戏，当你玩过一关之后，后面的关卡才会继续出现，这种类型的游戏除了每一关卡的场景不一样之外，游戏的逻辑都是一样的。所以，通常情况下，为了更容易快速地开发出产品，通常会有关卡编辑器来设计所有的关卡，类似于RPG游戏中的地图编辑工具，需要手动摆放所有的物体，如箱子、小猪等，当然，本书中不会介绍关卡编辑工具的开发，那将会是另一个庞大的工程。这里，我们将会完成一个关卡的逻辑实现，也就是游戏中主场景的实现，剩下的就是关卡编辑器所做的工作了。

图11-1是该游戏最终的场景截图。

图11-1 游戏界面

创建游戏主场景，相信对于我们来说是驾轻就熟的。首先，先来建立游戏框架，这里，我们只需要搭建框架，设置一个游戏主背景，然后加载游戏所需要的所有资源即可，游戏的 App 需要从 xengine 中的Game中继承：

(function(win){

var _Game = Game.extend({

init:function()

{

this._super();

var scm = this.sceneManager;

//创建场景

var x = (document.body.clientWidth-1024)*0.5;

var sc = scm.createScene([{"x":x, "w":1024, "h":600, "name":"main"}]);

//设置场景背景

sc.setBGImg("imgs/bg.png", 1);

//初始化box2d工具类

B2Util.init(0, 9.8, this, true);

this.loadRes(sc);

},

//加载资源

loadRes:function(scene)

{

var self=this;

ResManager.loadRes("data/res.json", function(){

//初始化精灵

self.initSprite();

self.run(self.frames);

});

},

//创建精灵

initSprite:function()

{

}

})

//定义全局TGame

win.TGame = new _Game();

}(window))

和之前创建游戏场景类似，在Game类的init方法中，先创建一个大小为1024×600的主场景，设置一张背景图片，然后调用B2Util工具类的方法初始化Box2D物理世界。

在前面的章节中，结合Box2D物理引擎的时候，是在GameApp中增加了几个和Box2D相关的函数。这里，我们把和 Box2D 相关的函数独立在一个 box2dutil.js 文件中，该文件中创建了一个单例类叫B2Util，通过这个类，可以完成Box2D物理世界的初始化工作以及创建基本的物理模型的基本方法。

B2Util的完整代码如下：

/*

Box2D帮助类

*/

(function(win){

var _b2Util = win.B2Util = {

b2World:null,

b2Scale:30,

//游戏监听器

ltn:null,

//初始化

init:function(x, y, game, enabled)

{

//定义快捷访问对象

win.b2Vec2 = Box2D.Common.Math.b2Vec2;

win.b2AABB = Box2D.Collision.b2AABB;

win.b2BodyDef = Box2D.Dynamics.b2BodyDef;

win.b2Body = Box2D.Dynamics.b2Body;

win.b2FixtureDef = Box2D.Dynamics.b2FixtureDef;

win.b2Fixture = Box2D.Dynamics.b2Fixture;

win.b2World = Box2D.Dynamics.b2World;

win.b2MassData = Box2D.Collision.Shapes.b2MassData;

win.b2PolygonShape = Box2D.Collision.Shapes.b2PolygonShape;

win.b2CircleShape = Box2D.Collision.Shapes.b2CircleShape;

win.b2DebugDraw = Box2D.Dynamics.b2DebugDraw;

win.b2MouseJointDef = Box2D.Dynamics.Joints.b2MouseJointDef;

win.b2ContactListener = Box2D.Dynamics.b2ContactListener;

win.b2ContactFilter = Box2D.Dynamics.b2ContactFilter;

//初始box2d世界

var bw = this.b2World = new b2World(new b2Vec2(x, y), true);

this.ltn = new AppEventListener({

"beforeRender":function(){

bw.Step(1/game.frames, 6, 1);

bw.ClearForces();

bw.DrawDebugData();

}

});

this.ltn.enabled = enabled;

game.addListener(this.ltn);

},

//设置显示窗口

setDebugDraw:function(context)

{

var debugDraw=new b2DebugDraw();

debugDraw.SetSprite(context);

debugDraw.SetDrawScale(this.b2Scale);

debugDraw.SetFillAlpha(0);

debugDraw.SetAlpha(0);

debugDraw.SetLineThickness(1.0);

debugDraw.SetFlags(b2DebugDraw.e_shapeBit | b2DebugDraw.e_jointBit);

this.b2World.SetDebugDraw(debugDraw);

},

//转换长度到Box2D世界

lenToB2d:function(pixel)

{

return pixel/this.b2Scale;

},

//转换Box2D长度到屏幕像素

lenToScn:function(b2Len)

{

return this.b2Scale*b2Len;

},

//创建圆形body

createRoundBody:function(r, x, y, density, friction, restitution, type)

{

//定义为圆形状

var bodyDef = new b2BodyDef();

bodyDef.type = (type==null)?b2Body.b2_dynamicBody:type;

bodyDef.position.Set(this.lenToB2d(x||0), this.lenToB2d(y||0));

bodyDef.angularDamping = 1.6;

//创建形状

var ba = new b2CircleShape(this.lenToB2d(r));

//创建材质

var fix = new b2FixtureDef();

fix.shape = ba;

fix.density = density||2;

fix.friction = friction||0.5;

fix.restitution = restitution||0.5;

//创建物体

var body = this.b2World.CreateBody(bodyDef);

if(type==b2Body.b2_staticBody)

{

body.CreateFixture2(ba);

}

else

{

body.CreateFixture(fix);

}

return body;

},

//创建矩形形body

createRectBody:function(x, y, w, h, ang, density, friction, restitution, type)

{

//定义floor类型

var bodyDef = new b2BodyDef();

bodyDef.type = (type==null)?b2Body.b2_dynamicBody:type;

bodyDef.position.Set(this.lenToB2d(x||0), this.lenToB2d(y||0));

//创建形状

var box = new b2PolygonShape();

box.SetAsBox(this.lenToB2d(w||0), this.lenToB2d(h||0));

//创建材质

var fix = new b2FixtureDef();

fix.shape = box;

fix.density = density||2;

fix.friction =friction||0.5;

fix.restitution = restitution||0.5;

fix.groupIndex = 1;

//创建body

var body = this.b2World.CreateBody(bodyDef);

body.SetAngle(MathUtil.deg2rad(ang||0));

//创建材质

if(type==b2Body.b2_staticBody)

{

body.CreateFixture2(box);

}

else

{

body.CreateFixture(fix);

}

return body;

},

//设置碰撞监听器

setContactListener:function(ltn)

{

this.b2World.SetContactListener(ltn);

},

//设置碰撞过滤器

setContactFilter:function(flt)

{

this.b2World.SetContactFilter(flt);

},

//移除物体

remove:function(b)

{

this.b2World.DestroyBody(b);

}

}

}(window))

对前面Box2D相关章节熟悉的读者应该对这部分代码已经非常熟悉了，可以看到，实际上B2Util 类中包含了初始化 Box2D 物理世界、创建矩形以及圆形 body，同时，为了能够在游戏引擎的每帧渲染之前能够更新整个物理世界，在init初始化部分给game加入了游戏监听器。

此外，为了能够在整个游戏中对物体进行碰撞检测，在B2Util中使用setContactListener给Box2D物理世界添加了碰撞监听器，关于这个监听器的具体使用在接下来的章节中会详细解说，总之，这个B2Util的主要工作就是把Box2D的工作独立到一个类中进行管理。

当然，和之前所有的应用一样，在游戏能够运行之前必须先要准备好游戏所需要的所有资源，巧妇难为无米之炊，亮丽的画面和动听的音乐会给你的游戏增色不少。准备好素材之后，我们需要把这些素材归类，然后写在一个配置文件中，在游戏机制章节中，介绍了一种配置文件的方式。这里，我们采用同样的方式进行配置，这里涉及两个主要的配置文件，一个文件保存了游戏中需要的所有资源，比如图片、声音等，另一个文件保存了所有精灵的帧数据，比如以下的res.json保存了所有的资源：

{

"image":

[

{"name":"pig","src":"imgs/pig.png"},

{"name":"bird", "src":"imgs/bird.png"},

{"name":"bk1", "src":"imgs/bk1.png"},

{"name":"bk2", "src":"imgs/bk2.png"},

{"name":"bk3", "src":"imgs/bk3.png"},

{"name":"sshort", "src":"imgs/dg.png"},

{"name":"boom", "src":"imgs/boom.png"},

{"name":"boom1", "src":"imgs/boom1.png"},

{"name":"boom2", "src":"imgs/boom2.png"},

{"name":"boom3", "src":"imgs/boom3.png"},

{"name":"cd", "src":"imgs/cd.png"}

],

"frame":

[

{"name":"sprite","src":"data/sprite_frames.json"}

]

}

另一个文件sprite_frames则保存了所有精灵帧动画数据：

{

"pig":{

"type":1,

"img":"pig",

"rc":[1, 3, 49, 49],

"animations":{

"def":[0, 2]

}

},

"bird":{

"type":1,

"img":"bird",

"rc":[1, 3, 50, 45],

"animations":{

"def":[0]

}

},

"boom":{

"type":1,

"img":"boom",

"rc":[4, 1, 65, 57],

"animations":{

"def":[0, 5]

}

},

"boom1":{

"type":1,

"img":"boom1",

"rc":[1, 3, 11, 27],

"animations":{

"def":[0],

"f1":[1],

"f2":[2]

}

},

"boom2":{

"type":1,

"img":"boom2",

"rc":[3, 1, 29, 36],

"animations":{

"def":[0],

"f1":[1],

"f2":[2]

}

},

"boom3":{

"type":1,

"img":"boom3",

"rc":[3, 1, 37, 10],

"animations":{

"def":[0],

"f1":[1],

"f2":[2]

}

}

}

好了，一切资源准备好，且主体框架搭建起来之后，就需要针对游戏中的精灵进行设计。本游戏中涉及的游戏精灵类大多都是和Box2D物理引擎相关的精灵，也就是说，事实上，在这个游戏当中，小鸟、小猪、砖块等场景中出现的元素的运动都是由 Box2D 物理引擎去管理的。为了得到代码最大化的重用，可以先设计出一个基于Box2D和渲染引擎的精灵类，这个类作为游戏中其他精灵的父类，大致的UML图形表示如图11-2所示。

图11-2 B2Sprite类

接下来详细解说B2Sprite类的实现。

11.2 创建B2Sprite 类

与前面所描述的一样，B2Sprite类是从Sprite类继承而来，同时为了让它能够接受Box2D物理世界的管理，该类需要重写Sprite精灵类的update方法，保证该精灵类的位置、角度等物理数据和通过Box2D世界中的计算出的数据保持一致，这样我们就得到了以下B2Sprite的具体实现类。

/*

* B2Sprite 类

*/

(function(){

//从引擎的Sprite继承

B2Sprite = Sprite.extend({

init:function()

{

//物体的血量，碰撞后减少

this.hp = 12;

//是否碰撞过

this.isColled = false;

this.isDead = false;

//对应Box2D中的物体引用

this.b2Obj = null;

this._super();

},

//绑定一个b2Obj对象

bindB2Obj:function(b2Obj)

{

//绑定Box2D中物体

this.b2Obj = b2Obj;

b2Obj.SetUserData(this);

},

//同步到box2d物理数据

syncToB2:function()

{

if(this.b2Obj!=null)

{

//获取物体当前位置

var pos = this.b2Obj.GetPosition();

this.x = B2Util.lenToScn(pos.x);

this.y = B2Util.lenToScn(pos.y);

//获取物体角度

var ang = this.b2Obj.GetAngle();

this.deg = MathUtil.rad2deg(ang);

}

},

//减少血量

reduceHP:function(del)

{

this.hp-=del;

},

//是否达到移除要求

isRemovable:function()

{

return (this.hp<=0);

},

//死亡，由子类具体实现效果

dead:function()

{

//删除Box2D中的关联对象

if(this.b2Obj)

{

B2Util.remove(this.b2Obj);

}

//从场景管理器中删除对象

this.owner.removeRObj(this);

this.isDead = true;

},

//复写更新方法

update:function()

{

this.syncToB2();

}

})

//注册pig到类工厂

B2Sprite.ClassName = "B2Sprite";

ClassFactory.regClass(B2Sprite.ClassName, B2Sprite);

}())

在这个类中，最重要的是重写了 update()方法，在该方法中又调用了 syncToB2()的方法， syncToB2()方法实际上是用来同步Box2D引擎中的物理数据。我们知道，在进行渲染之前，Game类中会调用Box2D中的step()方法来计算物理世界中所有body的物理数据。所以，我们只需要在update()方法中，把该精灵和Box2D物理世界中关联的body的物理数据进行同步就达到了需要的效果，也就是说，实际上，这个时候精灵的运动就不受渲染引擎所控制，而完全交给Box2D进行处理了。

为了能够让精灵和Box2D中的某一个body进行关联，在B2Sprite中存在一个b2Obj属性，该属性指向一个Box2D中的body引用，通过B2Sprite类的bindB2Obj(b2Obj)方法，可以建立Box2D中的一个body和该精灵对象的双向关联，这样就可以相互查找到对方，便于后期的处理。

除了最重要的update()方法外，还有关于精灵死亡的方法dead()。为了减少引擎的负担，当一个游戏中的精灵不再需要的时候，最好把他移除掉，所以在本例中增加了一个 dead()方法，当一个B2Sprite精灵死亡的时候，就必须销毁它，当然，同时也需要把和它在Box2D物理世界中关联的body删除。至于什么时候调用dead()方法，这就需要根据游戏中的逻辑需要了。本例中，为了简化起见，给B2Sprite方法增加了一个hp属性，表示该精灵的血量，当该精灵的血量减少为0时，就达到了可以被移除的条件，此时就可以调用dead方法销毁该精灵了。

好了，接下来该我们的主角出场了，小鸟是本游戏中最为复杂的一个类，相信当你实现小鸟类之后，其他的就易如反掌了。

11.3 创建Bird类

小鸟的活动状态相对比较复杂，为了能够更好地了解小鸟的运动情况，我们可以先把小鸟可能处于的状态列举出来，然后分析出每个状态之间的转换关系，以及小鸟在每个状态中的活动情况。

11.3.1 Bird 状态分析及实现

大体上，小鸟会处于以下几个状态。

（1）空闲：刚创建的小鸟处于此状态。

（2）发射就绪：小鸟从地面飞上树杈。

（3）准备发射：小鸟已经在树杈上，可以人为控制小鸟发射。

（4）飞翔：小鸟已经发射出去。

（5）死亡：小鸟发射出去后已经停止运动。

由上述状态活动，我们可以得到如图11-3所示的UML状态图。

图11-3 小鸟状态图

分析出小鸟的状态图之后，就可以根据小鸟的每个状态进行具体的实现，当然，小鸟需要从B2Sprite类继承，于是我们首先得到了以下的最简单的Bird类，然后，可以一步步增加需要的方法。

/*

* Bird 类

*/

(function(){

//从引擎的Sprite继承

Bird = B2Sprite.extend({

init:function(r)

{

this.r=r;

this.sCtx = new StateContext(this);

this.regState();

this._super();

this.sCtx.changeState("idle");

},

//注册状态类

regState:function()

{

this.sCtx.regState(new IdleState("idle", this.sCtx));

this.sCtx.regState(new GoReadyState("goReady", this.sCtx));

this.sCtx.regState(new GoFlyState("goFly", this.sCtx));

this.sCtx.regState(new FlyState("fly", this.sCtx));

this.sCtx.regState(new DeadState("dead", this.sCtx));

},

//更新状态机

update:function()

{

this.sCtx.update();

},

//获取当前状态

getState:function()

{

return this.sCtx.currState.type;

},

//添加到b2world世界中

addToB2Word:function()

{

var body=B2Util.createRoundBody(this.r, this.x, this.y, 1.5, 0.1, 0.6);

this.bindB2Obj(body);

}

})

ClassFactory.regClass(Bird.ClassName, Bird);

}())

对前面算法一章中的有限状态机很清楚的读者应该不会对以上代码陌生，同样，在小鸟类中，我们使用了有限状态机对小鸟进行管理。当然，前面我们说过，关于精灵的所有活动都可以放在update()方法中进行更新，对于简单的精灵来说固然可以，但如果精灵过于复杂或者当项目的规模逐渐扩大后，update()方法将代码冗长，难于维护，所以使用状态机管理，可以关注精灵的某一个状态点，便于扩展和维护。接下来就需要对每一个状态类进行处理了，需要注意的是，所有的状态类都要从State类继承而来。

首先是IdleState类，表示小鸟空闲时的活动，这里处理得比较简单，什么都没有处理。

//bird状态类

var IdleState = State.extend({

});

接下来是发射就绪状态，小鸟从空闲状态直到飞上树杈的整个活动都处于这个状态，对于这个状态的处理稍微复杂一些。这里需要注意两点，一是，小鸟飞到树杈顶部的路线是一个抛物线，二是，小鸟在飞到顶部的时候会有一个向后旋转的处理过程。

关于第一项小鸟飞到树杈顶部的抛物线处理，如图11-4所示，圆形表示小鸟，黑色矩形表示弹弓，由此可以建立一个以弹弓最底部为原心，弹弓和小鸟所在的水平线为x轴，弹弓所在直线为y轴的坐标系。在这个坐标系当中，我们以弹弓的最高点作为抛物线的顶点，小鸟所在的位置作为抛物线和 x轴的交点，此时，该抛物线的方程应该是y=ax^2+c，c的值就是抛物线的顶点坐标，a的值可以通过a=−c/(b^2)得到，b 就是小鸟初始位置所在的坐标，当然，这个坐标是相对于弹弓的。

图11-4 小鸟飞行到树杈上的抛物线图

有了这个抛物线方程，剩下的工作就好做了。小鸟飞到弹弓上时，可以设定小鸟的x方向的增量dx为某一个数值，这样在进行update的时候，可以根据小鸟当前的x坐标得到y坐标，最终就可以设置小鸟的坐标了。

另外一个问题是小鸟飞行到弹弓的过程中有一个旋转的过程，这里，就需要在小鸟update的过程中设置一个旋转角度，这个角度如何计算呢？我们可以先设定小鸟飞行到弹弓的时间为t，假设游戏每帧运行的时间为t_1，则小鸟每帧旋转的角度为360/(t/t_1)，这样小鸟飞行到弹弓上的时候就会翻360°。

一旦小鸟的x坐标小于等于0，此时，我们就认为小鸟已经到达了弹弓的顶端，同样需要注意的是，这个坐标也是相对于弹弓的。

以下伪代码给出了发射就绪状态的小鸟状态：

//定义Bird初始状态类

var GoReadyState = State.extend({

enter:function()

{

//以树杈中心为顶点，小鸟初始位置构造抛物线，获取抛物线的几个参数坐标

},

change:function()

{

//如果小鸟已经到达弹弓顶部则进入准备发射状态

},

update:function()

{

获取小鸟相对弹弓的x坐标

计算抛物线上小鸟的y坐标

如果刚好到达弹弓顶端则准备好可以发射小鸟

}

});

好了，一旦小鸟到达了弹弓顶端，此时，小鸟的状态就成了准备发射状态，一旦小鸟处于这个状态，就可以拖动小鸟进行弹射。

以下代码是小鸟发射状态的伪代码：

//发射状态

var GoFlyState = State.extend({

enter:function()

{

//初始Box2D的监听器

},

change:function()

{

如果释放鼠标，则表示松开小鸟，则

{

如果拖动距离太短

{

小鸟位置还原

}

否则

{

改变小鸟到飞行状态

}

}

},

update:function()

{

获取鼠标变动大小

如果拖动的长度小于20则还原

否则根据鼠标新位置改变小鸟的位置

}

});

在松开小鸟之前，小鸟的运动是跟随鼠标或者玩家的手指，此时，不需要创建一个Box2D对象与之关联。当松开小鸟的时候，小鸟就进入了飞行状态，此时可以创建一个Box2D的body对象，让小鸟受物理引擎影响活动。

松开小鸟的时候，此时可以给小鸟一个冲量，让它具有初始速度，当然这个冲量的大小应该根据橡皮筋的长短呈线性变化，同时，我们需要给这个冲量设置一个最大值，以免小鸟的速度过快，这个最大值的大小可能需要反复的测试，在Box2D中可以使用以下3种方式改变物体速度。

（1）ApplyForce方法会在刚体上施加一个力，在力的作用下物体会慢慢运动动起来。

（2）ApplyImpulse直接影响刚体的速度。通过ApplyImpulse方法添加的速度会与刚体原有的速度叠加，产生新的速度。

（3）setLinearVelocity方法直接设置刚体的速度。

有了以上方法，就可以在松开小鸟的时刻，给小鸟设置一个合适的速度值了。

关于小鸟飞行状态的伪代码如下：

//定义飞行状态

var FlyState = State.extend({

enter:function()

{

创建和Box2D物理空间中相关的body

给小鸟一个适当的冲量

},

change:function()

{

获取小鸟当前速度，速度为0或者超出屏幕范围，则转换到死亡

},

update:function()

{

同步Box2D中body的位置

}

});

最后一个状态是小鸟的死亡状态，这个状态也比较简单，通常的活动就是调用父类B2Sprite的dead方法，其伪代码如下：

//死亡状态

var DeadState = State.extend({

update:function()

{

调用B2Sprite的dead方法

}

});

基本上来说，小鸟的几个状态类会直接控制小鸟的每个状态的活动，这几个状态类也是小鸟类的核心部分。

到这里，我们做到了以下事情。

（1）创建小鸟，小鸟会以抛物线方式跳跃到弹弓上。

（2）可以拖动小鸟进行弹射。

（3）当小鸟处于飞行时，如果速度变为0，或者超出了屏幕，则会消亡。

后面我们会给出Bird类的全部代码，接下来，需要处理小鸟飞行的轨迹，当小鸟在飞行的时候，可以绘制出小鸟飞行的痕迹。

11.3.2 绘制Bird 飞行轨迹

小鸟在飞行的时候会留下轨迹，这个效果的实现还算简单，在小鸟处于飞行状态的时候，我们可以对小鸟飞行的位置进行采样，把这些位置保存在一个数组中，然后循环这个数组进行绘制，此处，我们定义一个BirdTrack类完成此工作。

该类的具体实现如下：

/*

* BirdTrack 类

*/

(function(){

//从引擎的Sprite继承

BirdTrack = Sprite.extend({

init:function()

{

//轨迹点集

this.tracks = [];

this._super();

this.setAnims(ResManager.getAnimationsByName("sprite", "cd"));

},

render:function(ctx)

{

var sc;

var f = this.getNextFrame();

for(var i=0;i<this.tracks.length;i+=2)

{

sc=0.3-(i%3)*0.1;

ctx.save();

ctx.translate(this.tracks[i], this.tracks[i+1]);

var hw = 0.5*this.w;

var hh = 0.5*this.h;

ctx.scale(sc, sc);

ctx.drawImage(f[0], f[1], f[2], f[3], f[4], -hw, -hh, this.w, this.h);

ctx.restore();

}

}

})

//注册Bird到类工厂

BirdTrack.ClassName = "BirdTrack";

ClassFactory.regClass(BirdTrack.ClassName, BirdTrack);

}())

BirdTrack类继承至Sprite对象，包含了一个tracks数组，该数组中保存了小鸟飞行时候的采样点集，存储的形式是[x0, y0, x1, y1, …]，同时，该类复写了Sprite 的Render 方法，在Render方法中循环点集，在每个点的位置绘制一个图案。

事实上这个地方，BirdTrack每帧都需要对所有的点集进行绘制，由于小鸟飞行了之后，以前的轨迹是不需要进行擦除的，只需要对最新的点绘制即可，这样可以提高一些效率。在前面的引擎介绍中，我们提过，xengine中针对Scene的管理还可以进行分层，也就是一个Scene可以具有多个canvas层，这样，我们可以把BirdTrack和小鸟绘制在不同的层中，BirdTrack中所处的层是不需要擦出的，只需要绘制小鸟最新的位置即可，但目前xengine中，Scene只有一层canvas，所以需要在每帧中绘制所有的采集点。

接下来的工作是需要在何时进行采集工作。

正如我们前面讨论小鸟的状态类所说的，当小鸟处于飞行的时候，是比较合适采集轨迹的，我们可以在小鸟的FlyState中采集轨迹，伪代码如下：

//定义飞行状态

var FlyState = State.extend({

enter:function()

{

创建和Box2D物理空间中相关的body

给小鸟一个适当的冲量

设置最大采集间隔数以及一个累加变量

},

change:function()

{

获取小鸟当前速度，速度为0或者超出屏幕范围，则转换到死亡

},

update:function()

{

同步Box2D中body的位置

如果小鸟还没有碰撞到任何物体则

{

如果累加变量大于最大采集间隔数

则

累加变量置0，并把小鸟当前位置添加到BirdTrack中

否则

累加变量自增

}

}

});

这里应该是一个相对简单的工作，所需要注意的是如何设置一个恰当的最大采集间隔数，因为如果设置不当的话，会导致轨迹间的点要么十分密集，要么就很稀疏。很显然，橡皮筋的力量越大，小鸟飞行得越快，这个数就应该越小，反之，这个数就应该越大，所以我们可以先找到一个最小值和一个最大值，然后根据拖拉橡皮筋的长度在这两个值之间做插值，这样就比较合适了。

接下来的工作就来了，这里我们只需要绘制小鸟第一次碰撞前的轨迹，也就是说一旦小鸟碰到任何东西，就停止采集点的工作，那我们如何检测小鸟是否碰撞到其他物体呢？所幸的是这一切，Box2D都帮我们做好了，让我们来看看Box2D中的碰撞检测。

11.3.3 Box2D中的碰撞检测

Box2D中使用接触监听器（Contact Listener）来处理物体之间的碰撞问题，一旦b2World中的物体发生了接触碰撞，则该监听器中的回调函数就会被触发，使用Contact Listener的步骤如下。

（1）创建Box2D.Dynamics.b2ContactListener监听器。

（2）设置监听器中的回调函数，在回调函数中处理逻辑。

接触监听器支持几种事件：开始（begin）、结束（end），、解答前（pre-solve）、解答后（post-solve）。

这几个事件的具体发生时间如下。

• begin事件。当两个fixture 开始有重叠时，事件会被触发。传感器和非传感器都会触发这个事件。这个事件只能在b2World的step()时发生。

• end 事件。当两个 fixture 不再重叠时，事件会被触发。传感器和非传感器都会触发这个事件。当一个body被摧毁时，事件也有可能被触发。所以这事件也有可能发生在时间步之外。

• pre-solve事件。在碰撞检测之后，但在碰撞求解之前，事件会被触发。

• post-solve事件。当你可以得到碰撞冲量（collision impulse）的结果时，post-solve 事件会发生。

这里，我们比较感兴趣的是Post-Solve事件，该事件表示有两个b2Contact对象相互接触，通过这个事件我们就知道哪些对象发生了碰撞，同时，还可以获得碰撞时的物体受到力的大小。这个就对我们有帮助了，我们就可以通过这个碰撞力量的大小来设定物体受伤害的程度了，当然伤害到一定程度，在本例中如果物体的hp减少到0，则表示物体死亡。

Post-Solve的flash版本原型如下：

PostSolve(contact:b2Contact, impulse:b2ContactImpulse):void

这个回调函数中包含两个参数，可以采用以下方式通过contact获取碰撞的两个物体：

var ba = con.GetFixtureA().GetBody(),

bb = con.GetFixtureB().GetBody();

然后，可以通过impulse的normalImpulses属性获取冲力，该力是一个向量。

知道以上原理后，我们就可以处理小鸟和其他物体的碰撞事件了，在本例中需要处理的逻辑主要涉及以下几类。

• 小鸟：小鸟如果碰到任何物体，就会设置小鸟的isColled 属性为true，就可以根据这个属性来确定是否需要继续采集小鸟的轨迹。当前的碰撞力量如果大于4，则显示小鸟的碰撞效果，典型的就是小鸟的羽毛到处飞散。

• 其他物体：如果当前碰撞力量小于 1.0 则忽略不计，否则物体的 hp 值就会减少，当达到可以移除的标准之后，将把该物体放到一个可移除的数组列表中。

这里需要注意的是，一旦物体达到了可以移除的条件，我们也不能在该回调函数中通过Box2D的DestroyBody方法来销毁对象。Box2D的参考手册中明确指出，不能直接在该回调函数中销毁对象，需要把销毁的物体放到一个临时的缓冲区中，然后在统一销毁。当然，销毁这些物体最好的时机就是在Game的监听器中，可以在Game的监听器的onAfterRender方法中集中进行销毁处理。

以下代码就是B2Ltn的具体实现：

/*

* Box2D 监听器类

*/

(function(){

B2Ltn =function()

{

this.ltn=new b2ContactListener();

this.initLtn();

}

B2Ltn.prototype.initLtn = function()

{

var self=this;

this.ltn.BeginContact = function(con){

};

//碰撞后处理

this.ltn.PostSolve = function(con, imp)

{

//第一只鸟发射前不计

if(TGame.avaBirdIdx == 0&&TGame.currBird.getState()!="fly")

{

return;

}

var v = imp.normalImpulses;

var ba = con.GetFixtureA().GetBody().GetUserData(),

bb = con.GetFixtureB().GetBody().GetUserData();

if(bb==null||ba==null||v[0]<1.0)

{

return;

}

var tmpSprite = ba;

//减少血量

for(var i=0;i<2;i++)

{

//地板不参与计算

if(tmpSprite=="floor")

{

tmpSprite=bb;

continue;

}

//小鸟不参与计算，小鸟停止运动后死亡

if(tmpSprite.constructor.ClassName==="Bird")

{

//力量大于4显示碰撞效果

if(v[0]>4)

{

tmpSprite.effColl();

}

tmpSprite.isColled = true;

tmpSprite = bb;

continue;

}

//移出物体

if(tmpSprite.isRemovable())

{

TGame.removableSprite[tmpSprite.name]=tmpSprite;

}

else

{

tmpSprite.reduceHP(v[0]);

}

tmpSprite = bb;

}

}

}

}())

这个类主要是在 PostSolve 回调函数中进行了处理，在第一次发射小鸟之前可以不用参与运算，其他情况下，只要场景中的物体发生了接触碰撞，则都会触发PostSolve回调函数，在该函数中就可以进行逻辑处理了。

碰撞这一块已经处理完毕，针对小鸟来说，还有最后一项工作，就是小鸟碰撞以及死亡后的效果处理。

11.3.4 Bird 碰撞效果处理

在前面的碰撞监听器中我们提到，如果小鸟碰到任何物体，就会出现羽毛到处飘散的效果，这是一个典型的粒子发生器问题，当然，这个效果也可以使用Box2D物理引擎来实现。要知道，进行物理模拟所消耗的CPU资源是比较大的，没有必要给物理引擎加重负担，所以，这里我们通过在算法章节中介绍的粒子发生器来实现类似的效果。

这里我们创建一个 Boom 类，该类从算法章节中的 Particle 类直接继承而来，我们只需要给这些粒子赋予不同的图形材质即可，所以得到Boom类的实现如下：

/*

* Bird 碰撞粒子效果类

*/

(function(){

//Bird碰撞粒子效果类

BoomParticle = Particle.extend({

init:function(sp, animName)

{

this._super();

//获取动画帧

var anim = ResManager.getAnimationsByName("sprite", sp);

//随机获取动画

var animNames = anim.getAllNames();

animName = animName||animNames[MathUtil.randInt(animNames.length)];

this.setAnims(anim, animName);

},

render:function(ctx)

{

var lratio=this.age/this.life;

var sc = this.sSize+(this.eSize-this.sSize)*lratio;

this.scaleX = this.scaleY = sc;

this._super(ctx);

}

});

//注册Boom到类工厂

BoomParticle.ClassName = "BoomParticle";

ClassFactory.regClass(BoomParticle.ClassName, BoomParticle);

}())

有了这个粒子类之后，就可以通过创建粒子发生器发射粒子，当然在进行发射离子的时候还需要调整相应的各种参数。

11.3.5 Bird 类的实现

到此为止，我们可以完成Bird的全部实现部分了，完整的Bird代码实现如下：

/*

* Bird 类

*/

(function(){

//从引擎的Sprite继承

Bird = B2Sprite.extend({

init:function(r)

{

this.r=r;

this.sCtx = new StateContext(this);

this.regState();

this.isGoFly = false;

this._super();

//设置bird血量

this.hp = 45;

//轨迹对象

this.track = null;

this.sCtx.changeState("idle");

},

//注册状态类

regState:function()

{

this.sCtx.regState(new IdleState("idle", this.sCtx));

this.sCtx.regState(new GoReadyState("goReady", this.sCtx));

this.sCtx.regState(new GoFlyState("goFly", this.sCtx));

this.sCtx.regState(new FlyState("fly", this.sCtx));

this.sCtx.regState(new DeadState("dead", this.sCtx));

},

//更新状态机

update:function()

{

this.sCtx.update();

},

//获取当前状态

getState:function()

{

return this.sCtx.currState.type;

},

//添加到b2world世界中

addToB2Word:function()

{

var body=B2Util.createRoundBody(this.r, this.x, this.y, 1.5, 0.1, 0.6);

this.bindB2Obj(body);

},

//弹射出去

shooter:function()

{

//定义弹弓中心

var c = TGame.getShootCenter();

//创建小鸟到弹弓中心向量

var vx = c[0]-this.x, vy = c[1]-this.y;

var len = Math.sqrt(vx*vx+vy*vy);

vx /= len;

vy /= len;

var force = TGame.power;

//定义弹弓最长80，最短20

var tLen = 80;

force *=(len/tLen);

this.b2Obj.ApplyImpulse(new b2Vec2(vx*force, vy*force), this.b2Obj.GetWorld Center());

return force;

},

//定义小鸟碰撞效果

effColl:function()

{

var emit=new Emit({

"lifeRange":[600, 900],

"sSize":1.2,

"eSize":0.1,

"maxNum":5,

"pos":[this.x, this.y],

"angRange":90,

"v":[-1, -1],

"vRange":[1, 1],

"a":[0.01, 0.01],

"aRange":[0.01, 0.01]

}, this.owner, BoomParticle.ClassName, ["boom1", "def"]); emit.jet();

},

//复写死亡方法

dead:function()

{

var self=this;

this._super();

TGame.createBoom(this.x, this.y, function(){

self.effColl();

})

}

})

//注册Bird到类工厂

Bird.ClassName = "Bird";

//bird状态类

var IdleState = State.extend({

});

//定义Bird初始状态类

var GoReadyState = State.extend({

enter:function()

{

var bird=this.ctx.owner;

//以树杈中心为顶点，小鸟初始位置构造抛物线

var sc = TGame.getShootCenter();

this.b = bird.x-sc[0];

this.c = TGame.skyLineY-sc[1];

this.a = -this.c/(this.b*this.b);

//设定time毫秒内移动到弹弓上

//每帧消耗的时间

var tPerFrame = 1000/TGame.frames;

var time = 600;

//计算time时间内移动到弹弓上的水平步数

var steps = time/tPerFrame;

//计算dx

bird.dx = -this.b/steps;

//计算每帧旋转的角度

this.sDeg = 360/steps;

},

change:function()

{

var bird=this.ctx.owner;

if(bird.isGoFly)

{

//准备好

this.ctx.changeState("goFly");

}

},

update:function()

{

var bird=this.ctx.owner;

var sc = TGame.getShootCenter();

bird.deg+=this.sDeg;

//计算横坐标位移

bird.x += bird.dx;

//计算相对弹弓的x坐标

var bx = bird.x-sc[0];

//计算抛物线y坐标

var by = this.a*bx*bx+this.c;

bird.y = TGame.skyLineY-by;

//如果刚好到达弹弓顶端则准备好可以发射小鸟

if(bx<=0)

{

bird.isGoFly=true;

}

}

});

//准备好状态

var GoFlyState = State.extend({

enter:function()

{

//初始监听器

TGame.initContactLtn();

this.reset = false;

},

change:function()

{

var bird=this.ctx.owner;

//释放鼠标

if(this.drag&&Mouse.gBtnState(0)==0)

{

this.drag=false;

//距离太短，恢复原状

if(this.reset)

{

bird.moveTo(this.ox, this.oy);

}

else

{

//飞行

this.ctx.changeState("fly");

}

}

},

update:function()

{

var bird=this.ctx.owner;

var sshort = TGame.sshort;

if(!this.drag&&Mouse.gBtnState(0)==1&&bird.isMouseIn())

{

this.drag=true;

this.ox = bird.x;

this.oy = bird.y;

}

//移动

if(this.drag)

{

var nx=this.ox+Mouse.gXOff(), ny=this.oy+Mouse.gYOff();

var c = TGame.getShootCenter();

var sx = c[0]-nx;

var sy = c[1]-ny;

var len = sx*sx+sy*sy;

//限制拖动的长度不超过80

if(len<6400)

{

bird.moveTo(nx, ny);

}

//限制拖动的长度小于20则还原

this.reset = (len<400)?true:false;

}

}

});

//定义飞行状态

var FlyState = State.extend({

enter:function()

{

var bird=this.ctx.owner;

//增加到Box2D物理空间中

bird.addToB2Word();

var f = bird.shooter();

//记录轨迹次数

this.step = MathUtil.lerp(6, 1, (f-TGame.power*0.2)/(TGame.power*0.8))|0;

this.tCount = this.step;

},

change:function()

{

var bird=this.ctx.owner;

var v = bird.b2Obj.GetLinearVelocity();

//速度为0或者超出屏幕范围，则转换到死亡

if(bird.x<-bird.r||bird.x>bird.owner.w+bird.r||(Math.abs(v.x)<0.1&&Math.

abs(v.y)<0.1)){

this.ctx.changeState("dead");

}

},

update:function()

{

var bird=this.ctx.owner;

bird.syncToB2();

//如果没有碰撞过

if(!bird.isColled)

{

if(++this.tCount>this.step)

{

this.tCount=0;

bird.track.tracks.push(bird.x, bird.y);

}

}

}

});

//死亡状态

var DeadState = State.extend({

update:function()

{

var bird=this.ctx.owner;

bird.dead();

}

});

ClassFactory.regClass(Bird.ClassName, Bird);

ClassFactory.regClass(BoomParticle.ClassName, BoomParticle);

}())

Bird类中小鸟死亡的时候会调用TGame的createBoom方法，该方法创建了一个简单的爆破效果，实际上就是创建了一个具有爆破效果的精灵，当显示到精灵最后一帧的时候，就从场景对象中删除该对象，具体代码如下：

//创建爆破效果

createBoom:function(x, y, callBack)

{

var sc=this.sceneManager.getScene("main");

//创建一个爆破的精灵

var obj = sc.createRObj(Sprite.ClassName);

obj.setAnims(ResManager.getAnimationsByName("sprite", "boom"));

obj.moveTo(x, y);

//重写update方法

obj.update = function()

{

if(this.animsCtrl.isFirstFrame()&&!this.isF)

{

this.isF=true;

callBack&&callBack();

}

//最后一帧播放完毕后死亡

if(this.animsCtrl.isLastFrame())

{

this.owner.removeRObj(this);

}

}

}

接下来的工作就相对轻松了，先来看看小猪类的实现。

11.4 Pig类的实现

小猪这个类没有太多复杂的状态，创建之后，小猪基本就像缩头乌龟一样静静躺在该在的地方，等待厄运的降临。当然，这个类同样需要从B2Sprite继承。

完整的Pig代码如下：

/*

* Pig 类

*/

(function(){

//从引擎的Sprite继承

Pig = B2Sprite.extend({

init:function(r)

{

this.r=r;

this.sCtx = new StateContext(this);

this.regState();

this._super();

this.score = 2000;

this.sCtx.changeState("idle");

},

//注册状态类

regState:function()

{

this.sCtx.regState(new IdleState("idle", this.sCtx));

},

//更新状态机

update:function()

{

this.sCtx.update();

},

//获取当前状态

getState:function()

{

return this.sCtx.currState.type;

},

//添加到b2world世界中

addToB2Word:function()

{

var body=B2Util.createRoundBody(this.r, this.x, this.y, 4, 0.5, 0.2);

this.bindB2Obj(body);

},

//复写死亡方法

dead:function()

{

this._super();

TGame.createBoom(this.x, this.y);

//显示分数

TGame.createScore(this.x, this.y, {"txt":this.score, "col":"green", "f_size":34,"l_time":1000});

}

})

//注册pig到类工厂

Pig.ClassName = "Pig";

var IdleState = State.extend({

change:function()

{

var pig=this.ctx.owner;

},

update:function()

{

var pig=this.ctx.owner;

pig.syncToB2();

}

});

ClassFactory.regClass(Pig.ClassName, Pig);

}())

这里，小猪只有一个状态即Idle状态类，同时，为了显示小猪死亡时的特殊效果，复写了B2Sprite的dead方法，给小猪死亡添加了一些爆破效果，以及显示小猪被摧毁后获得的分数。显示分数的效果调用了 TGame 类的 createScore 方法，该方法会创建一个文字对象，显示一段时间后逐渐消失，当然，具体需要什么效果，可以自行定义，方法的代码如下：

//创建分数文本

createScore:function(x, y, param)

{

varp=PubUtil.merge(param,{"x":x,"y":y,"txt":0,"col":"red","f_size":30,"l_time":800});

var sc = this.sceneManager.getScene("main");

var tObj = sc.createRObj(RenderObj.ClassName);

tObj.birth = FrameState.currTime;

tObj.life = p.l_time;

tObj.col = p.col;

tObj.txt = p.txt;

tObj.size = p.f_size;

tObj.x = x;

tObj.y = y;

tObj.tAlpha = 1.0;

tObj.dy = -1;

tObj.update = function(){

if(this.life>0)

{

var d=FrameState.currTime-this.birth;

if(d>this.life)

{

this.owner.removeRObj(this);

}

else

{

this.tAlpha=(1.0-d/this.life);

}

}

this.moveStep();

}

tObj.render = function(ctx){

ctx.save();

ctx.fillStyle = this.col;

ctx.globalAlpha = this.tAlpha;

ctx.font = "bold "+this.size+"px sans-serif";

var txt = this.txt;

var m = ctx.measureText(txt).width;

ctx.fillText(this.txt, this.x-m*0.5, this.y);

ctx.restore();

}

}

接下来创建Block障碍类。

11.5 Block 类的实现

这里我们可以把Block类当做所有可以活动的障碍物体，事实上场景中出现的石块、木块以及玻璃等物体只是物理特性和使用的材质不同，其他的逻辑处理都类似，所以，可以使用Block类进行统一处理。

基本上，Block的实现和Pig有些类似，状态也相对简单，整个的实现如下：

/*

* Block 类

*/

(function(){

//从引擎的Sprite继承

Block = B2Sprite.extend({

init:function(bType)

{

this.sCtx=new StateContext(this);

this.regState();

this.bType = bType;

this._super();

this.hp = 40;

this.score = 1000;

this.sCtx.changeState("idle");

},

//注册状态类

regState:function()

{

this.sCtx.regState(new IdleState("idle", this.sCtx));

},

//更新状态机

update:function()

{

this.sCtx.update();

},

//获取当前状态

getState:function()

{

return this.sCtx.currState.type;

},

//添加到b2world世界中

addToB2Word:function(t)

{

//定义材质数据，默认是木头

var density = 1,

friction = 0.8,

restitution = 0.3;

switch(this.bType)

{

case"glass":

density = 0.5;

break ;

case "stone":

density = 4;

restitution = 0.01;

break;

}

body=B2Util.createRectBody(this.x,this.y,this.w*0.5,this.h*0.5,this.deg,

density, friction, restitution);

this.bindB2Obj(body);

},

//定义block碰撞效果

effColl:function()

{

var boomName, mNum;

switch(this.bType)

{

case"glass":

boomName = "boom2";

mNum = 6;

break ;

default:

boomName = "boom3";

mNum = 12;

}

var emit = new Emit({

"lifeRange":[200, 600],

"sSize":1,

"eSize":0.2,

"maxNum":mNum,

"pos":[this.x, this.y],

"angRange":180,

"v":[-1.2, 1],

"vRange":[1, 1],

"a":[0.01, -0.01],

"aRange":[0.01, -0.01]

}, this.owner, BoomParticle.ClassName, [boomName]);

emit.jet();

},

//复写死亡方法

dead:function()

{

this._super();

this.effColl();

//显示分数

TGame.createScore(this.x, this.y, {"txt":this.score});

}

})

//注册Block到类工厂

Block.ClassName = "Block";

var IdleState = State.extend({

change:function()

{

var bk=this.ctx.owner;

},

update:function()

{

var bk=this.ctx.owner;

bk.syncToB2();

}

});

ClassFactory.regClass(Block.ClassName, Block);

}())

Block类复写了B2Sprite的addToB2Word的方法，根据物体类型的不同设置不同的物理参数，当然，这些参数的设置需要根据游戏的实际情况进行调整，总的来说，调整这些游戏中的数值也是一件令人比较头疼的事情。

最后，游戏中的各种精灵对象已经处理完毕，接下来的工作可以完成Tgame类的完整实现了。

11.6 TGame 类的实现

由于本例中只实现了一个关卡的场景部分，所以，我们需要做的工作就是创建小鸟、小猪以及各种障碍对象，同时把这些对象放到该放的位置，剩下的工作实际上就交给物理引擎来处理物体运动，渲染引擎进行渲染各种精灵对象了。

整个的Tgame的完整实现如下：

/*

TGame 游戏主类

*/

(function(win){

var _Game = Game.extend({

frames:60,

birds:[],

pigs:[],

blocks:[],

//记录所有可以销毁的精灵

removableSprite:{},

avaBirdIdx:-1,

currBird:null,

//弹弓对象

sshort:null,

floor:null,

//地平线高度

skyLineY:530,

//Box2D监听器

b2Ltn:null,

//弹弓最大拉力

power:27,

init:function()

{

this._super();

var scm = this.sceneManager;

//创建场景

var x = (document.body.clientWidth-1024)*0.5;

var sc = scm.createScene([{"x":x, "w":1024, "h":600, "name":"main"}]);

//设置场景背景

sc.setBGImg("imgs/bg.png", 1);

//初始化box2d工具类

B2Util.init(0, 9.8, this, true);

this.loadRes(sc);

},

//加载资源

loadRes:function(scene)

{

var self=this;

ResManager.loadRes("data/res.json", function(){

self.initSprite();

self.run(self.frames);

});

},

//创建精灵

initSprite:function()

{

//初始化地板

this.initFloor();

//创建弹弓

this.createSShort();

//初始化小鸟

this.initBird(15, 5);

//创建障碍

this.initBlock();

//初始化小猪

this.initPig();

//初始化各种监听器

this.initListener();

},

//创建小鸟

createBird:function(r, x, y)

{

var sc=this.sceneManager.getScene("main");

var bird = sc.createRObj(Bird.ClassName, [r]);

bird.setAnims(ResManager.getAnimationsByName("sprite", "bird"));

bird.moveTo(x, y);

bird.dx = -1;

bird.w = bird.h = bird.r*2;

//创建轨迹对象

bird.track = sc.createRObj(BirdTrack.ClassName);

this.birds.push(bird);

},

//创建障碍物体

createBlock:function(type, animName, x, y, w, h)

{

var sc=this.sceneManager.getScene("main");

var bk = sc.createRObj(Block.ClassName, [type]);

bk.setAnims(ResManager.getAnimationsByName("sprite", animName));

bk.moveTo(x, y);

this.blocks.push(bird);

},

//创建小猪

createPig:function(x, y)

{

var sc=this.sceneManager.getScene("main");

var p = sc.createRObj(Pig.ClassName, [16]);

p.setAnims(ResManager.getAnimationsByName("sprite", "pig"));

p.moveTo(x, y);

p.w = p.h = p.r*2;

p.setAnimSpeed(0.3);

p.addToB2Word();

this.pigs.push(p);

},

//初始化小鸟

initBird:function(r, num)

{

for(var i=0;i<num;i++)

{

this.createBird(r, MathUtil.randInt(130, 220), this.skyLineY-r);

}

},

//初始化障碍

initBlock:function()

{

var sc=this.sceneManager.getScene("main");

var bks=[

["stone", "lst", 840, 520, 203, 20, 0, 1000],

["wood", "mwood", 785, 459, 100, 20, 90],

["wood", "mwood", 899, 459, 100, 20, 90],

["wood", "mwood", 840, 400, 168, 22, 0, 60],

["wood", "mwood", 806, 340, 100, 20, 90],

["wood", "mwood", 872, 340, 100, 20, 90],

["wood", "mwood", 840, 280, 100, 20],

["glass", "sgs", 805, 470, 85, 22, 90, 10],

["glass", "sgs", 875, 470, 85, 22, 90, 10]

]

//创建

for(var i=0;i<bks.length;i++)

{

var bk=bks[i];

var p = sc.createRObj(Block.ClassName, [bk[0]]);

p.setAnims(ResManager.getAnimationsByName("sprite", bk[1]));

p.moveTo(bk[2], bk[3]);

p.w = bk[4], p.h=bk[5];

p.deg = bk[6]||0;

p.hp = bk[7]||p.hp;

p.addToB2Word();

this.blocks.push(p);

}

},

//初始化小猪

initPig:function()

{

var sc=this.sceneManager.getScene("main");

var pigs=[

[840, 490],

[840, 370]

];

for(var i=0;i<pigs.length;i++)

{

var pig=this.createPig(pigs[i][0], pigs[i][1]);

}

},

//创建弹弓

createSShort:function()

{

var sc=this.sceneManager.getScene("main");

//弹弓右边

var ss1 = sc.createRObj(Sprite.ClassName);

ss1.setAnims(ResManager.getAnimationsByName("sprite", "ss1"));

ss1.moveTo(90, this.skyLineY-ss1.h*0.5-10);

ss1.zIdx = -1;

//弹弓左边

var ss2 = sc.createRObj(Sprite.ClassName);

ss2.setAnims(ResManager.getAnimationsByName("sprite", "ss2"));

ss2.moveTo(79, this.skyLineY-ss2.h*0.5-15);

ss2.zIdx = 3;

this.sshort = ss1;

//找到弹弓左右边线端点

var c = this.getShootCenter();

var cx = c[0], cy = c[1]+4;

//弹弓右边线

var RLine = sc.createRObj(RenderObj.ClassName, ["RLine"]);

RLine.zIdx = -2;

//弹弓左边线

var LLine = sc.createRObj(RenderObj.ClassName, ["LLine"]);

LLine.zIdx = 2;

var fn = function(dx1, dy1){

return function(ctx){

var sx = cx, sy = cy, dx = dx1, dy = dy1;

var bird = TGame.currBird;

if(bird!=null&&bird.getState()=="goFly")

{

sx=bird.x;

sy = bird.y;

}

ctx.lineWidth = 8;

ctx.strokeStyle = "rgb(48, 23, 8)";

ctx.beginPath();

ctx.moveTo(sx, sy);

ctx.lineTo(dx, dy);

ctx.stroke();

}

}

//定义render方法

RLine.render = fn(cx+10, cy);

LLine.render = fn(cx-10, cy);

},

//创建地板，地板不需要绘制

initFloor:function()

{

var sc=this.sceneManager.getScene("main");

this.floor = B2Util.createRectBody(512, this.skyLineY, 600, 2, null, null,

null, null, b2Body.b2_staticBody);

//设置地板标记

this.floor.SetUserData("floor");

},

//初始化碰撞监听器

initContactLtn:function()

{

B2Util.setContactListener(this.b2Ltn.ltn);

},

//获取弹弓中心坐标

getShootCenter:function()

{

var sx=this.sshort.x-6, sy=this.skyLineY-this.sshort.h;

return [sx, sy];

},

//初始化game监听器

initListener:function()

{

var self=this;

var appLtn = new AppEventListener();

//添加app监听器

appLtn.onAfterRender = function(){

//控制小鸟开始准备

self.birdReady();

//处理精灵销毁

self.cleanSprite();

}

this.addListener(appLtn);

//设置box2d监听器

this.b2Ltn = new B2Ltn();

},

//处理精灵销毁

cleanSprite:function()

{

//removableSprite保留了所有应该销毁的精灵对象，调用该对象的dead方法销毁

for(var i in this.removableSprite)

{

this.removableSprite[i].dead();

}

this.removableSprite={};

},

//控制小鸟开始准备

birdReady:function()

{

if((this.currBird==null)||(this.currBird!=null&&this.avaBirdIdx<this.birds.

length-1&&this.currBird.getState()=="fly"))

{

this.currBird=this.birds[++this.avaBirdIdx];

this.currBird.sCtx.changeState("goReady");

}

},

//创建爆炸精灵

createBoom:function(x, y, callBack)

{

var sc=this.sceneManager.getScene("main");

//创建一个爆破的精灵

var obj = sc.createRObj(Sprite.ClassName);

obj.setAnims(ResManager.getAnimationsByName("sprite", "boom"));

obj.moveTo(x, y);

//重写update方法

obj.update = function()

{

if(this.animsCtrl.isFirstFrame()&&!this.isF)

{

this.isF=true;

callBack&&callBack();

}

//最后一帧播放完毕后死亡

if(this.animsCtrl.isLastFrame())

{

this.owner.removeRObj(this);

}

}

},

//创建文本

createScore:function(x, y, param)

{

varp=PubUtil.merge(param,{"x":x,"y":y,"txt":0,"col":"red","f_size":

30, "l_time":800});

var sc = this.sceneManager.getScene("main");

var tObj = sc.createRObj(RenderObj.ClassName);

tObj.birth = FrameState.currTime;

tObj.life = p.l_time;

tObj.col = p.col;

tObj.txt = p.txt;

tObj.size = p.f_size;

tObj.x = x;

tObj.y = y;

tObj.tAlpha = 1.0;

tObj.dy = -1;

tObj.update = function(){

if(this.life>0)

{

var d=FrameState.currTime-this.birth;

if(d>this.life)

{

this.owner.removeRObj(this);

}

else

{

this.tAlpha=(1.0-d/this.life);

}

}

this.moveStep();

}

tObj.render=function(ctx){

ctx.save();

ctx.fillStyle = this.col;

ctx.globalAlpha = this.tAlpha;

ctx.font = "bold "+this.size+"px sans-serif";

var txt = this.txt;

var m = ctx.measureText(txt).width;

ctx.fillText(this.txt, this.x-m*0.5, this.y);

ctx.restore();

}

}

})

//定义全局TGame

win.TGame = new _Game();

}(window))

这里有以下几个地方需要注意。

（1）销毁对象的时机，前面的介绍中我们详细解说了，所有被碰撞的物体在 Box2D 的监听器回调函数中进行检测，如果到了可以删除的时候，需要把它们添加到TGame的removableSprite数组中，然后在TGame中的监听器的onAfterRender方法中循环removableSprite数组，检测是否有需要删除的对象，有则调用该对象的dead方法进行销毁。

（2）小鸟和弹弓的位置，为了保证小鸟在弹弓上面的时候正好在两个树杈中间，这里需要做特殊处理。一是需要把树杈拆分成两个图片，而且需要使用两个精灵对象进行处理，如果只采用一个精灵的话，就会出现要么小鸟覆盖整个弹弓，要么小鸟被弹弓覆盖。

（3）初始化障碍时候，障碍的数据需要进行适当的调整，当然，这是个比较费神的操作，有时间的话可以为小鸟定制一个关卡编辑工具，用于定位各种物体的数据，实在不行的话，可以使用绘图工具比如Photoshop，按照图像大小得到各种物体的位置数据。

当然，另一种更好的方式是把游戏中的场景数据使用XML或者JSON格式进行配置，如同资源文件一样，场景中的精灵的一些属性，比如位置、质量、采用的纹理等，限于时间关系，聪明的读者应该会做得更加完善。

总之，好的代码不是一次性就能写出来的，引用一条比较经典的话叫做："好的代码是重构出来的"，对此我深信不疑。

11.7 小结

好了，基于物理引擎Box2D的游戏部分就介绍到这里，千里之行，始于足下，不要一开始就尝试做一些非常大型的游戏，不积跬步无以至千里，刚开始学做游戏的时候，可以尝试做大量的小游戏，时间长了自然会得心应手了，预祝大家尽早做出好玩的游戏。

附录A 游戏的历史

游戏的历史就是游戏机的发展史，从电子游戏机诞生到现在大致可以分为8个时代，每个时代以具有历史意义的新一代的游戏主机推出作为里程碑，以下是电子游戏几十年的发展史。

A.1 诞生

据美国专利局可查的资料表明，最早的电子娱乐装置专利属于美国，在 1947 年 1 月，美国人汤玛斯·T. 沟史密斯二世（Thomas T. Goldsmith Jr.）与艾斯托·雷·曼申请了一款名叫阴极射线管娱乐装置的设计。阴极射线管就是我们常说的CRT，在当时，CRT技术主要应用在雷达显示器等国防工程中，利用CRT以及机房的巨型计算机，组成了最早的视频游戏机。利用这台巨大的电子设备，汤玛斯设计了一款模拟导弹射击飞机的游戏，可以利用各种旋钮控制导弹的速度和航线轰炸飞机，限于设备的昂贵和专业性，当然，这款机器是无法普及的，所以这款最早的视频游戏装置没有让大家所熟知。

随后，在 1958 年的时候，隶属于美国能源部的布鲁克海文国家实验室，负责计算机工程的物理学家威利·希金博特姆为了让实验室游客待得时间更长，以便他们更多地了解实验室的各种科研成果，他利用示波器和研制出了一款称为Tennis for Two（见图A-1）的双人对战网球模拟程序，游客可以通过一个小的盒子装置（最早的游戏手柄)控制网球的发射和接球，正是这款诞生于实验室的小游戏让"电子游戏"这个名词从此登上了历史舞台。

真正意义上的运行在电脑上的第一款交互式的电子游戏叫做 Space War，即宇宙战争，这款游戏由当时在美国麻省理工学院的学生史帝芬·罗素开发出来。限于当时的条件，Space War同样使用了CRT来作为显示装置，这款类似于Xbox上几何战争画面的太空射击类游戏，它允许两个玩家各自操作一架飞行器利用武器摧毁对方，这款游戏最终在 DEC 公司的 PDP-1 小型机上运行，它也是被认为第一个广为流传和具有影响力的游戏，图A-2显示了PDP-1小型机和Space War运行画面。

图A-1 Teneis for Two 的游戏画面

图A-2 PDP-1 小型机和Space War游戏画面

A.2 萌芽

如果把电子游戏的发展比喻成飞机飞行的过程，那么20世纪60年代是飞机开始启动的时候，而20世纪七八十年代就是飞机加速起飞的时期。

20世纪70年代初，美国加州的电气工程师诺兰·布什纳尔发现了电子娱乐行业的巨大发展前景，于1971年设计出了世界上第一台商用的电子游戏机，称其为"电脑空间"。该机器是现代投币式街机的始祖，随该机发布的游戏就是经过诺兰·布什纳尔改良后的Space War。由于种种原因，该游戏并没有像想象中的一样受到大家的欢迎，但它成功地为电子游戏市场商业化运作树立了一个标杆，因此，他被誉为"电子游戏之父"。在美国营销大师吉恩·兰德勒姆的全球畅销书《改变世界的13位男性》中，布什纳尔被列为和比尔·盖茨、乔布斯同类的传奇人物，事实上，苹果的创始人史蒂夫·乔布斯也曾经在布什纳尔手下工作。

对于初期的失败，布什纳尔并没有选择放弃，1972年和他的朋友泰德·巴内各出了250美金注册成立了历史上第一家电子游戏公司——Atari（雅达利），据说，公司的名字取自于日语中的围棋用语"将军"的发音。1975年，Atari上的第一款游戏Pong诞生，Pong游戏是由当时刚毕业的学生Alcom开发，它是一款可以四个人操作的乒乓球游戏，游戏的画面相当简陋，整个屏幕就像一个乒乓球台，由玩家控制代表球拍的两条竖线，把代表乒乓球的光点打给对方。图A-3展示了"电脑空间"和Pong的游戏画面，当然，以现在的眼光来看，它是相当原始的，但正是这款原始的游戏为雅达利的成功叩开了大门，在以后不到两年时间里，雅达利的Pong游戏机卖出了1万台左右，美国的几乎所有娱乐场所都响起了乒乒乓乓的声音。就在雅达利取得胜利并准备向家庭游戏机市场进军的时候，此时，一家名为 Magnavox 的公司，借助"奥德赛"电视游戏机已经打开了家用游戏机市场，从此，家用游戏机登上历史舞台，任天堂、索尼、世嘉等一批优秀的电子游戏商开始崭露头角。

从1972年开始到1977年这一阶段可以认为是游戏机上的第一个时代，这一时代以第一台家用游戏机"奥德赛"诞生开始，直到Atari的2600发行结束。

第一台适合家庭使用的游戏机"奥德赛"诞生于1972年，拉尔夫·贝尔是该游戏机的发明者。早在1967年的时候，拉尔夫和他的小组就成功研究出第一款可以在电视机上玩的视频互动游戏Chase（追击），该游戏甚至配备了光线枪，玩家可以依靠这把特殊的外设和游戏进行互动。1972年5月奥德赛正式发行，当时的售价高达100美金，据统计，该主机的销售数量达到了33万台，图A-4展示的是奥德塞主机。

图A-3 电脑空间和Pong

图A-4 第一台家用游戏机——奥德塞

随着雅达利的成功，大家都看到了市场上一块大的蛋糕，仅仅在雅达利成立一年之后，就有20多家公司参与到电子游戏市场的竞争中。随后，1976年Coleco发行了代号为"Telstar"（通信卫星）的家用游戏机，同年，Fairchild推出了第一款可编程的家用游戏主机——Channel F（F频道），图A-5是Telstar和Channel F游戏主机。Channel F是历史上第一款可以换卡的游戏主机，虽然它具有历史意义，但真正依靠可换卡而大获成功的第一款游戏主机是雅达利的Atari2600。

1977 年推出了具有影响一个时代的视频游戏系统——VCS（Video Computer System，电视电脑系统），也就是人们常说的Atari2600电子游戏机。Atari2600把电视机作为游戏的显示终端，把游戏手柄从游戏主机通过电线独立出来作为控制器（在此之前的游戏机的结构中，手柄和主机是一体化结构）的模式，成为了后期家用游戏机的典范，同时，它也是一款可自由换卡的游戏主机，这样既提高了主机的使用寿命，同时也降低了用户的购买成本。Atari2600发行后的第一年就创造了高达3.3亿美元的销售额，成为了当年最受欢迎的圣诞礼物，截至1982年的时候，在全美的普及率达到了每3户家庭1台的程度。伴随着这一游戏机行业的开山之祖的发行，诞生了许多让人耳熟能详的游戏，比如《打砖块》、《太空侵略者》、《警察捉小偷》、《海底救人》等，日本著名游戏公司namco的游戏《吃豆人》则成了Atari2600主机上最卖座的游戏。笔者还记得20世纪80年代初期，有人用Atari2600放在大街上供人们娱乐，刚出来的时候，排队玩游戏的人几乎就快赶上过节买火车票的人了，可见其魅力之大，图A-6展示了Atari2600游戏机。

图A-5 Telstar和Channel-F

图A-6 Atari2600

1982年，由于Atari2600受到硬件的限制，Atari2600的表现能力与街机的表现效果相去甚远，同时，由于Atari2600上面充斥着大量的粗制烂造的、雷同的、低水平的游戏（就如同现在的许多WebGame一样），使得消费者对游戏产业产生了不信任感，导致了消费者和销售渠道共同抵制的现象。在美国，造成了大量游戏软件厂商倒闭，很多软件从业人员失业，几乎导致了美国市场的崩溃，这就是游戏史上著名的"Atari shock"（雅达利崩溃）事件。到此，电子游戏之父——布什纳尔一手创立的Atari公司破产，Atari从此淡出了人们的视线，在西方，一个Atari倒下了，此时的东方，另一个巨人——任天堂站立起来，成为了游戏史上的新一任领军人物。

任天堂公司是一家家族企业，成立于1889年，刚开始的名字叫做Marufuku，1951年公司更名为Nintendo，它代表的寓意是"命由天定"，创建者是山内房治郎。创始之初，公司以经营一种名为花札的日本手制纸牌为生，这样，公司依靠卖纸牌为生，浑浑噩噩的度过了几十年， 1949年山内溥上台继承公司，为任天堂公司在随后几十年中成为电子游戏业的王者立下了汗马功劳。极具商业头脑的山内上台之后仅仅用了6年的时间就让任天堂成为了纸牌制作业的龙头老大，1962年任天堂在日本上市，其优秀的业绩让公司的股票大涨，此后，任天堂便尝试开拓其他行业，但都以失败告终。当山内得知Atari在美国获得的巨大成功之后，经过了仔细的分析研究，他决定进军这一拥有巨大潜力的市场。在三菱公司的技术支持下，任天堂于1983年7月15日发布了一款具有划时代意义的游戏主机—Famicom，简称FC，如图A-7所示，无数经典游戏首次登上这款长达20年生命力之久的主机，比如《超级玛丽》、《最终幻想》、《勇者斗恶龙》、《大金刚》、《塞尔达传说》、《魂斗罗》等。可以说，现代的游戏剥除掉华丽的外表后，几乎都可以在FC上找到它的原型，至此，一个属于任天堂的时代来临了。

图A-7 FC红白机

从1977年Atari开始直到1983年新的王者FC诞生可以认为是游戏机史上的第二个时代。

A.3 任天堂的崛起

20世纪80年代开始，电子游戏开始流行，20世纪80年代到90年代初的10年可以说是街机的黄金10年，在这短短的10年当中，街机游戏以迅雷不及掩耳的方式发展。由于当时的娱乐项目非常缺少，电脑、网络还属于高高在上的产物，加上当时的家用游戏机的画面和质量远远赶不上街机的效果，这一系列的因素为街机的迅速发展创造了条件，使得在短短几年内，街机游戏就遍布了全国的大街小巷，许多经典的游戏至今仍脍炙人口，比如Namco著名的射击游戏《小蜜蜂》、《吃豆人》、《坦克大战》，任天堂的《大金刚》、《玛丽兄弟》，Capcom的《名将》、《圆桌骑士》、《魔界村》、经典的射击类游戏《19××》系列、格斗类游戏《Street Fighter》系列、《漫画英雄》系列、《X-Man》系列、《三国》系列，Konami的《兵峰系列》，以及SNK的大量格斗类经典作品系列《KOF》系列、《饿狼》系列、《侍魂》系列等。

除了这些在街机游戏史上留下足迹的经典游戏外，街机平台上也诞生了许多始祖型游戏，比如体育竞争游戏类的始祖《Hyper Olympic》（奥运会），格斗游戏类的始祖《Karate Dou》（空手道）等。

在街机游戏风靡全球的时候，家用机市场的发展也正如火如荼地进行，但毫无疑问20世纪80年代的家用游戏机是任天堂的时代。从任天堂1983年发布的主机FC开始，就进入了游戏机的第3个重要的时代，累计1亿多台的FC销量奠定了任天堂在家用机的王者时代，FC红白机几乎统治了近8年的家用游戏机市场。相信许多70后和80后的玩家对这台机种怀有深刻的感情，甚至许多玩家都是从FC时代认识电子游戏的。随着FC的流行，国内也出现了FC的兼容机种——小霸王学习机，美其名曰小霸王电脑学习机，相信这是一台大多数玩家可以打着学习的幌子而购买的游戏机。

FC的成功不是历史的偶然，它是任天堂在综合分析了大量的市场以及总结了Atari失败的经验教训后的产物。首先，FC集中了Atari主机的几乎所有优点，简单的换卡系统、方便的游戏控制手柄、可以直接使用家家户户都有的电视作为输出，但FC的价格更低廉，硬件更先进，游戏效果更出色。

其次，除了拥有强大的硬件研发团队外，任天堂也拥有一支一流的软件开发团队，作为世界发行量最大的游戏——玛丽系列，是任天堂的招牌游戏，几乎每代任天堂的硬件发售时，都是由玛丽系列打开市场。玛丽系列的创始人，被誉为世界排名第一的游戏设计师——宫本茂，创造了诸多的游戏神话，诸如创下了历史上最高销售收入的制作人，创造了日式ARPG的始祖游戏——塞尔达传说系列，被誉为游戏界的斯皮尔伯格等。据说当初微软刚进军游戏市场的时候，曾想以当时宫本10倍的薪水挖走宫本茂，结果被他以"任天堂有很多自己的朋友"为由拒绝。

尽管任天堂拥有一流的开发团队，但山内也低估了市场的需求能力，随着 FC 红白机的销量渐增，游戏的数量已经供不应求了，任天堂意识到这块市场独木难支，于是，于1984年将任天堂游戏平台向第三方开发商开放，最初加盟的有街机业的巨头 Namco 和电脑游戏业大腕Hudson，随后加盟的的其他公司有：大东（TAITO）、卡普空（CAPCOM）、杰力可（JALECO）和科乐美（KONAMI）等。随着软件合作厂商的加入，任天堂意识到导致 Atari 游戏主机失败的最主要的原因是游戏软件的低质量，为了保证游戏的高质量，任天堂对与其合作的第三方厂商提出了许多近乎无理的苛刻要求，包括限制厂商每年发行游戏的数量，游戏必须由任天堂进行审核，卡带由任天堂统一制作，费用由各厂商承担等。尽管合作的条件如此苛刻，但在任天堂公司的巨大影响力下，这些电子游戏厂商还是选择和任天堂合作，此时的 FC 如同插上了翅膀的猛虎，为任天堂打下了半壁江山。

FC能够成功除了有优秀的设计开发团队外，还有一个重要的因素就是移植了大量的街机游戏，比如《小蜜蜂》、《吃豆人》、《兵峰》、《魂斗罗》、《街头霸王》等。尽管 FC 的性能相比街机来说还是有不小的距离，但这种能让玩家足不出户，在家中就可以体验到街机游戏的感觉，几乎得到了所有玩家的认可，所以说，街机游戏为FC的成功也打下了良好的基础，成为FC的探路先锋。除了借助街机的势头外，FC上还诞生了许多街机上无法玩到的、优秀的RPG（Role player game，角色扮演）游戏，在当时充斥着大量动作游戏的年代，RPG 游戏的诞生无疑成为了电子游戏史上一道靓丽的风景线。FC上著名的RPG游戏有日本国民级的游戏——《勇者》系列、《最终幻想》系列、《塞尔达传说》系列等，这些系列的续作延续至今，成为RPG史上的传奇。1988年在这款主机上创作的《超级玛丽3》（唯一一款进入日本的最具有权威的游戏杂志——Fami通白金殿堂的2D玛丽系列游戏，Fami通的评分可以说代表着游戏业的标准）更是成为了FC对抗当时Sega公司发布新一代主机——MD的杀手锏，成为在游戏机史上利用软件大作来抗衡新主机发售的典型案例。

任天堂赚取巨额利润引起了许多厂商的眼红，许多电子厂商开始挑战任天堂在游戏市场的霸主地位，其中包括另一街机巨头厂商——Sega公司。在FC红白机处于研发过程中，Sega公司从任天堂公司挖走一位负责FC游戏主机研发的主管，从中获取了FC主机的各种技术资料，同样于1983年7月15日推出了自己的首款游戏主机——SG1000。在销售的初期SG1000的销量还和FC难分伯仲，随着时间的延长，SG1000逐渐显示了其颓势，由于其操作手柄极为不便，同时又缺乏强有力的软件开发商为其开发游戏，优秀的开发厂商几乎都加入了任天堂阵营，所以不久，SG1000 就以失败告终。随后即使Sega在SG1000 基础上研发了SG1000II、MARK III以及MASTER SYSTEM 等改进机型，即使Sega 连续使用4 代主机围剿FC，但依旧无法挽回Sega在8位游戏机市场的失败局势（这一时代的游戏机硬件主要采用8位数据总线的CPU），直到20世纪80年代末期Sega推出16位元游戏机之后，市场的天平才开始向它倾斜。

1986年雅达利公司发布了Atari7800游戏机，想依靠它上演咸鱼大翻身计划，虽然7800游戏机完全兼容Atari2600所有游戏，但由于"Atari shock"影响巨大，导致几乎没有一家软件公司想为它开发游戏，最终导致雅达利无力回天，逐渐走向衰败的道路。

参与FC时代竞争的还有著名的日本电器巨头——NEC公司，NEC公司于1987年10月发布了8位游戏机史上功能最为强大的电子游戏机——PC-Engine。除了采用8位的CPU外，它还使用了一颗16位的图形显示芯片，大大提高了游戏机的图形表现能力，其显示效果远远超出了FC的画面，让它几乎达到了和街机等同的效果。此外，它还是第一台使用CD-ROM作为游戏介质的游戏机，开创了CD-ROM为媒介的先河。这款性能卓越的游戏机开始动摇了FC的统治地位，为了对抗PC-E的大容量游戏系统，任天堂于1986年发布了FC磁碟机系统，但由于其在磁碟机系统上制定的一系列的霸王条款，导致任天堂的前两位软件发行厂商——Hudson和Namco的强烈不满，于是，这两家顶级的软件制作商协助PC-E游戏机的发行。借助两大软件厂商的鼎立支持以及PC-E优秀的游戏画面，PC-E抢占了任天堂的部分市场，虽然没有导致任天堂伤筋动骨，但的确动摇了任天堂的统治地位，使得任天堂在电子游戏市场独领风骚的局面开始转变，一代游戏机霸王开始走下坡路，从此，游戏机市场开始显现群雄争霸的局面。相关主机的图片如图A-8所示。

图A-8 FC同时代各主要机种

另外一个具有纪念意义的事件是，1984年1月24日，Macintosh电脑问世，开创了图形操作系统的先河，但由于其高达 2 000 美金的售价让寻常百姓望而却步，能够在电脑上享受到游戏的乐趣在当时确实是一件相当奢侈的事情。

A.4 群雄争霸，任氏独大

20世纪80年代初中期，8位机的时代几乎全都属于FC，20世纪80年代末期到90年代，则是游戏机史上群雄割据、百花齐放的年代。这一时期是属于家用游戏机的黄金年代，由于PC-E率先使用了16位图形显示芯片，具有新的历史意义，所以游戏机史上的第4个时代从1987年PC-E开始，直到1994年32位新的主机出现。

首先，掀起真正意义上的16位机时代序幕的公司是Sega，从SG1000开始，Sega卧薪尝胆了5年时间，于1988年10月发布了新一代的16位游戏主机—SegaMD。因为是Sega公司发行的第5代游戏主机，所以通常人们也称其为世嘉5代机，之前的4代主机都臣服于FC之下，而这款主机是史上第一台真正意义上的 16 位主机。在 MD 发行之初，由于当时的电子游戏机的市场争霸主要集中在PC-E和FC上，所以MD在日本市场没有受到太多的关注，但是在欧美市场，MD 受到了极大的欢迎，高素质游戏画面、大量的从街机移植的高品质游戏让玩家能够在家中得到媲美街机的感受，这也符合MD发布时"把街机搬回家"的口号，让MD主机风光一时。1991年Sega发行其招牌动作游戏—音速索尼克，这只满世界乱串的刺猬头形象的主角把动作游戏发挥到了极致，给玩家带来了前所未有的体验，同时，也成为了世嘉公司的新的吉祥物，正如这只勇往直前、不停向前奔跑的 sonic 一样，Sega 公司也加入了挑战任天堂霸主地位的行列。相信，带给大多数70、80后国内玩家的游戏机中FC如果排第一，那 MD 则是当仁不让的第二的位置。MD 上也出了许多经典的游戏，比如《超级忍》、《魂斗罗》、《幽游白书》、《索尼克》系列、《梦幻之星》系列、《梦幻模拟战》系列，以及大量的街机移植游戏等。

为了收回被Sega和PC-E所占领的电子游戏市场，任天堂公司决定打破公司产品FC一枝独秀的局面，一块新的处女地被任天堂发掘，充分展示了山内的商业头脑和独到的眼光，正是这块新的乐土又一次让任氏江山得以稳固。这块新的市场就是便携式掌机市场，任天堂于1989年4 月发布了其第一款掌上游戏机——Game Boy，尽管Game Boy不是当时市面上的第一款掌机，但无疑它是掌机史上最耀眼的明星，没有之一，拥有累计超过1亿台的销售量（如果算上整个GB家族，累计销量多达2亿台），统治掌机市场长达10年之久，拥有所有掌机史上销量最高的游戏——口袋妖怪系列，口袋妖怪系列的销量曾一度让机能远胜于GB的其他次世代机种感到难堪。带着任天堂的"随时随地可以玩游戏"的理念以及山内在掌机市场的雄心壮志，GB 不负任天堂的众望，是使得任天堂成为掌机领域王者的不二功臣，图A-9展示了GB家族系列掌机。

图A-9 GB大家族

除了开拓新的领域之外，为了抵抗来自 SegaMD16 位掌机的挑战，任天堂意识到光靠 FC继续啃老是不行的，至于 GB 带来的辉煌未来也不是山内在短期能够预见的，研制 FC 的接班人成了当务之急。于是，任天堂于1991年发布了FC的后续机种SFC（Super Famicom，超任）用以抗衡MD，由于SFC发布比MD晚一段时间，所以，SFC拥有更好的图像表现力，甚至， SFC 的图形芯片中拥有了基本的 3D 硬件加速能力。音效处理方面，由于任天堂和当时的音乐巨头—Sony合作，所以SFC在音乐处理方面超过MD不少。强劲的硬件机能带来了比其他主机更为柔和鲜艳的画面，更有令人痴迷的音乐，这使 SFC 在制作大受亚洲市场欢迎的 RPG游戏时效果不同凡响，在RGP平台上出现了超多的RPG大作游戏，诸如《最终幻想》系列、《幻想传说》系列，《塞尔达》系列、《圣剑》系列、《女神转生》系列、《勇者》系列、《火纹》系列、《皇家骑士团》系列、《时空之轮》等，可以说SFC是当时RGP游戏的集大成者，登上Fami通白金殿堂的SFC游戏高达15款，大部分都是RPG类游戏，特别是《塞尔达传说之众神的三角力量》是唯一一款获得39分（满分40分）的16位主机游戏（其他的都是32位游戏机种游戏）。Fami通白金殿堂的游戏都是精品中的精品，仅在SFC主机上受到如此高评价的游戏数量上就超过了Sega所有主机的游戏，Sega的MD无法与之媲美，甚至后来的Sega32位游戏机——土星也没有达到如此高度，唯一比较遗憾的是，由于SFC使用的CPU频率较MD低太多，几乎只有MD的CPU一半的频率。导致SFC的精品动作游戏没有MD的多，MD上耳熟能详的动作游戏有《超级忍》、《魂斗罗》、《幽游白书》、《索尼克》、《阿拉丁》、《米老鼠和唐老鸭》等，后面两款游戏也是MD进入Fami通白金殿堂仅有的两款游戏。尽管在动作游戏上SFC的优势较少，但也不乏动作游戏的精品之作，比如《街霸2》（Fami通白金游戏）、《大金刚》系列（色彩绚丽的画面和流畅的手感让许多32位主机游戏感到脸红）、《洛克人X》系列（以高难度著称的动作游戏）、《超级玛丽》系列（不用多说，任天堂的招牌游戏系列）。SFC上的《玛丽赛车》在2009年的吉尼斯世界纪录评比全球10款最佳游戏中排名第一，曾经风靡一时的韩国网络游戏——跑跑卡丁车也大量借鉴了该游戏。在SFC上笼罩着不计其数的赞誉的光环，最终， SFC和MD的16位主机以SFC的胜利而告终，当然，Sega的MD也并没有到达一败涂地的地步，MD 虽然在亚洲市场失利，但是在欧美的市场销量超过 SFC，其主要原因得归结于欧美普遍偏爱于动作游戏，而亚洲人则钟情于RPG游戏。

虽然16位主机之争中，SFC巩固了任天堂的霸主地位，成为任天堂继FC后任氏江山的顶梁柱，但也是因为SFC的关系，成就了另一个电子游戏业的巨头——Sony，导致了任天堂的在电子游戏业的霸主地位易主，真是成也萧何，败也萧何。

20世纪80年代末期，Sony在家电行业遇到发展瓶颈，当意识到多媒体市场发展的巨大前景后，Sony选择了和任天堂公司合作。时值任天堂公司在开发SFC上遇到技术困难，正好遇到拥有强大电子硬件技术背景的 Sony 抛过来的橄榄枝，两者一拍即和。此时，Sony 公司在当时 VIDEO DISK 事业部室长久多良木健的带领下和任天堂公司于 1989 年正式启动代号为"Playstation-X"的SFC CD-ROM 计划，虽然山内一直并不看好CD-ROM作为游戏媒介，但为了抵御Sega即将发售的MD-CD主机，他也答应了在SFC上搭载CD-ROM系统。可惜两者的联姻就如同一场色彩斑斓的肥皂泡一样，虽然五彩斑斓，但禁不起时间的考验，美国任天堂公司的总裁霍华德·林肯发现了两者签定的协议之间存在着漏洞，利用该漏洞会使Sony直接影响到任天堂在电子游戏业的领导地位。于是，任天堂一方面和Sony虚与委蛇，另一方面却搭上了荷兰著名家电制造厂商PHILIPS。1991年，美国芝加哥举行的消费电子展览会上，SONY正式在其展位发表了和任天堂合作开发的SFC 扩充CD-ROM的主机PLAY STATION，但在第二天，任天堂和PHILP公司举行了记者招待会宣布共同开发SFC搭载用CD-ROM主机，此举引起了Sony的恐慌，而任天堂给予的回复是这两个CD-ROM产品属于同一产品线，它们之间并不矛盾。至此，Sony终于明白自己被任天堂给耍了，而任天堂怎么也想不到它脚踏两条船的行为最终差点葬送了自己在游戏业的江山，正如在热恋中受到感情伤害的女人是比较危险的一样，作为日本电子行业的龙头老大，Sony 实在无法容忍自己的尊严受到任天堂的伤害。鉴于在 PS-X计划中投入了大量的人力和资金以及在久多良木健的强烈的建议下，Sony决定全面进军电子游戏领域。这一决定在电子游戏史上具有历史意义，任天堂从此走向衰败，电子游戏史上的三足鼎立时代即将到来。得罪了太多的优秀厂商，以及任天堂历来霸道的作风为它将来走向失败的道路埋下了定时炸弹，出来混，迟早是要还的。

同时期，参与16位游戏机竞争的还有SNK于1991年发布的NEO-GEO主机。虽然SNK拥有一大批热爱格斗游戏的忠实粉丝，虽然当时它的硬件机能远远超过当时的MD和SFC，甚至可以和32位游戏机媲美，但由于其高昂的价格（甚至超过了32位游戏机的王者PS）以及缺乏足够的游戏软件作为支撑，所以此款主机也在SFC席卷世界的热浪中淹没。事实又一次证明，光依靠硬件是远远不够的，高品质的游戏软件才是决定游戏机成败的关键。第4代游戏主机如图A-10所示。

图A-10 第4 代游戏主机

A.5 异军突起，江山易主

自从Sony和任天堂的PS-X计划胎死腹中，Sony决定进军电子游戏领域开始，属于任天堂的一片天空开始渐渐产生了变化。

1993年，Sony宣布成立了SCE（Sony电脑娱乐公司），并把PS主机纳入第一期开发计划中，由久多良木健担任SCE的社长。鉴于任天堂公司成功的经验，久多良木健认为一款游戏主机成功的关键在于丰富高质量的软件。于是，在SCE创立之初，久多良木健拜访了多家知名的游戏制作厂商，希望能够得到第三方软件商的鼎立支持，但由于游戏理念的不同（久多良木健极力推广3D游戏，而其他厂商则认为2D游戏才是主流）以及Sony在游戏机领域的零经验导致这些公司并不看好Sony。然而，功夫不负有心人，事情终于发生了转机，一款来自Sega街机的格斗游戏《VR快打》改变了诸多游戏厂商的想法，3D效果带来的视觉冲击感是2D游戏远远达不到的，著名格斗游戏厂商Capcom看到《VR快打》惊人的效果后表示全力支持PS，其他厂商也纷纷表示加入到PS的开发阵营。真是无心插柳柳成荫，可以说是Sega公司意外地拯救了PS，后来在1998年的久多良木健访谈中，他就曾经说："PS能够有今天的地位，都必须要感谢Sega的帮助。"Sega 公司的确是一家令人敬佩的公司，屡战屡败，屡败屡战，可惜的是生不逢时，16位机时代被SFC打败，32位机时代又遭逢PS这一猛虎，最终于2000年后淡出家用机市场。

1993 年，松下发布了第一款32位的游戏主机——3DO，从此拉开了次世代主机的新一轮战争，游戏机的第 5 个时代也从此刻开始来临，3D 游戏也从此开始了蓬勃的发展。尽管 3DO的性能在当时的家用机上首屈一指，但如同大多数失败的游戏机一样，昂贵的价格（在北美的售价高达699美金）以及缺少软件厂商的支持，使得这位32位游戏机的先驱迅速消亡，真是长江后浪推前浪，前浪死在沙滩上，做得早不一定是好事啊。

随着3DO打响了新一代游戏机战斗的序幕，Sony和Sega不甘寂寞，于1994年同时发布了PlayStation和SegaSaturn，丰富的游戏内容、直逼电影的CG动画效果、极具视觉冲击的3D效果颠覆了玩家传统的游戏认知。

1994年11月，Sega发布了其第6代游戏主机SegaSaturn（土星），凭借街机游戏《VR快打》的巨大影响，作为SS的首发游戏，《VR战士》为SS在发布首日创下17万台的佳绩做出了巨大贡献，大多数玩家都是冲着这款游戏而购买 SS。虽然在发售的初期，SS 的销量大大超过了Sony的PS，但由于PS发售稍晚，定价较低，同时在3D性能上强过SS，最终，SS没有守住胜利的势头，惜败于PS下。尽管SS没有获得最终的胜利，但SS上也涌现了一批经典游戏，比如《VR 战士 2》（Fami 通白金殿堂游戏）、《格兰蒂亚》传说、《恶魔城月下》、《真女神转生之恶魔召唤师》（Fami通白金殿堂游戏）等。值得一提的是，在SS的诸多白金殿堂游戏中，绝大部分也是Sega公司自身的作品，展现出Sega强大的游戏研发能力。在这款主机上，也发售过国产游戏RPG中的神话——《仙剑奇侠传》，相信《仙剑奇侠传》在国产游戏中的地位都不言而喻了，一方面，相对于 3D 的画质来说，仙剑奇侠传的 2D 画面稍显不足，导致仙剑在Fami通的评分并不高，销量也不尽如人意，甚至还比不上PS上发布的另一款国产游戏《射雕英雄传》，另一个值得深思的问题是，《仙剑奇侠传》无论从剧情还是音乐、画面来说，和顶级的RPG游戏制造商制作的游戏还是有一定的差距，即使是参与16位游戏的竞争，相信还是很难和SFC上一系列的RPG神作相比。这里，我们得感谢那些无偿进行游戏汉化的辛勤工作者，没有他们的努力，我们也无法领略到许多RPG名作的魅力。

1994年12月，伴随着Sony的"1－2－3，所有游戏在这里集结！"的经典广告语，Play Station正式发售，在发布的当天，首批出货的10万台中午就销售一空，甚至连索尼社长大贺典雄的孙子都不得不空手而归，从此，PS正式迈出了走向次世代游戏主机王者宝座的一步。强于SS的硬件机能、相比SS更低的价格，特别是PS上强大3D图形加速卡能够让软件开发商挖掘更大的潜力，使得更多优秀的软件开发商加入这一阵营，这一切都注定了 PS 将是一款走向成功的游戏主机。虽然在发售之初，PS和SS几乎是一直以齐头并进的步伐前进，甚至在1995年SS一度强压PS一头，这一年中，SS和PS进入竞争白热化阶段。为了聚集更多的人气，Sega宣布与SNK合作，将会移植SNK街机多款知名格斗游戏在SS中，同年，SS更是凭借超人气作品《VR战士2》拉大了与PS的差距，《VR战士2》在Fami通上的评分高达39分。SS这一年的销量几乎达到了PS的2倍，SS也达到了生命中的顶峰时期，而PS则略显颓势，整个1995年是属于SS的一年。

随着软件开发商逐渐吃透了硬件的机能，能够开发出更能展现机能的作品，特别是 PS 上《铁拳》和《山脊赛车》惊人的3D表现让玩家逐渐了解到PS的3D性能更加优越。也许是上天更加钟爱Sony，给了Sony第二次翻盘的机会，1996年2月9日，任天堂又一次出卖了盟友，逼走了日本顶尖的RPG游戏制作商Square，在这一天，Square发布了其倾注心血制作的第一款SRPG 类型的游戏《圣龙传说》，同时为该游戏制定 90 万本的首期出货量任天堂一改往日的分发方式，为了转嫁风险，将《圣龙传说》一次性地抛给小卖店（以往采用的是按5∶3∶2的方式分批给经销商），库存的积压造成了恐慌性的抛售，导致了《圣龙传说》的价格急剧下跌，这一手段让 Square 损失了十多亿日元，几乎导致 Square 濒临破产。任天堂的这一做法彻底让Square寒了心，在2月底，Square在东京的新作发表会上公开宣布与任天堂决裂，全面进军PS阵营，同时，在当天公布了RPG神作《最终幻想7》的Demo画面。在Square的巨大号召下，很多软件厂商也纷纷表示支持PS，由于日本玩家大多都对RPG类游戏情有独钟，加上FF系列的巨大影响力，所以Square实际上从任天堂带走了大量玩家，为PS占领新一代的主机市场吹响了胜利集结号。加入PS阵营后，Square得以大展拳脚，在PS上发布的RPG游戏几乎都在Fami通的白金殿堂上榜上有名，比如《最终幻想7》、《最终幻想8》、《最终幻想9》、《Tobal2》、《沙加开拓者2》、《穿越时空》等，同时也拥有PS主机上唯一款Fami通上的满分游戏《放浪冒险谭》，不愧为RPG的王者。

在1996年的美国洛杉矶第二届E3（E3是Electronic Entertainment Expo3个单词第一个字母组成的缩写，意思是"电子娱乐展"，E3在电子游戏中的地位如同奥斯卡在电影界的地位）大展上，出现了Sega、Nitendo和Sony三足鼎立的局面，但明显此次展会的主角是Sony的PS，此次展会上PS公布了RPG游戏《妖精战士》，Konami公司也公布了专为PS打造的RPG游戏《幻想水浒传》。

随后的时间里，在众多优秀的软件开发商的支持下，PS逐渐拉大了和SS的差距，随着PS上的经典大作一一登上Fami通的白金殿堂，SS败势尽显。在1997年的Sony的PS庆功会上，应邀参加的另一RPG巨头Enix正式宣布《勇者斗恶龙7》将在PS上推出，如果说Square的加入吹响了PS胜利的集结号，那Enix的加入则是吹响了PS胜利的冲锋号。在日本，《勇者》系列被称为日本国民级游戏，其地位站在所有RPG的顶峰。到此时，新一轮的游戏主机战争大局已定，PS成为最终的胜利者，奠定了Sony随后10年内成为电玩领域的独领风骚的霸王，这一次，任天堂在电子游戏业的统治地位易主，为它固执霸道的作风付出了惨痛的代价。

虽然 SS 和 PS 的竞争以失败告终，但 SS 也为我们留下了数不清的优秀作品，比如《VR战士2》（白金殿堂游戏）、《格兰蒂亚》（土星最强RPG游戏，150人耗时4年完成）、《樱花大战》、《真·女神转生之恶魔召唤师》、《光明力量3》等。

在PS和SS正逐渐开始抢占32位机市场的时候，任天堂也没有闲着，在游戏软件方面，依旧采用FC时代《超级玛丽3》的经典牵制策略——采用成熟的游戏软件对抗新一代的主机，不过，此次的主角换成了比玛丽还要历史悠久的大金刚。此次，英国著名游戏开发商Rareware公司凭借自己精湛的实力赢得了任天堂的信任，任天堂头一次把自己的品牌形象交给了外国厂商，甚至公开打出了"16位主机上实现32位机的效果"。Rareware公司果然不负众望，在SS发售4天后，经典的ACT游戏——《超级大金刚》上市，这是SFC上首款超过32Mbit容量的游戏，完美的多重卷轴系统，如梦幻般的游戏场景，流畅的动作与舒适的操作手感让当时许多新一代次世代机游戏感到羞愧。这款代表着2D时代颠峰的横版动作游戏全球销量高达930万，而当时SS上最畅销的游戏《VR战士2》全球销量还不到200万，Rareware的另一款经典FPS游戏《007黄金眼》（Fami通白金游戏）则为任天堂的N64打下北美市场立下了汗马功劳，任天堂的以软克硬的策略又一次获得了成功。

当然，任天堂也意识到SFC的使命已经结束，仅依靠游戏软件抵抗次世大潮只是权益之计，在一方面大量发布SFC游戏之时，山内也向市场宣布了任天堂的新一代主机的计划，甚至还口出狂言说道，如果带CD-ROM的游戏主机销量能超过100万台，他就头朝下走路，明显把矛头指向了当时的SS和PS。1995年，Sega和Sony同时宣布SS和PS销量达到100万台，山内的一句戏言虽然没有兑现，但他的下一步决定直接上演了任天堂的滑铁卢战役，从此跌下了占据电子游戏达10多年之久的王座。

为了应对异军突起的SS和PS，1995年7月，山内仓促决定将当时技术并不十分成熟的32位掌机VB（Virtual Boy）推上前线用以抵抗SS 和PS，这是历史上真正意义上的第一款具有3D效果的游戏主机，显示效果就如同现在的3DS掌机一样，效果令人惊叹，现在还可以使用VB模拟器搭配红蓝眼镜体验VB游戏的乐趣。但由于当时技术上的限制，VB游戏的色彩只有红黑两色，而且造型十分难看，被《时代周刊》评为最糟糕的发明和奇丑无比的科技产品。鉴于当时的3D显像的不成熟加上软件厂商的普遍不看好，这款奇特的主机在北美销售半年多就下架，成了任天堂历史上最短命的主机。虽然这款32位主机未被市场看好，但它的一系列新的创意被后来的任天堂的新一代掌机的王者3DS所继承，远远超过Sony的新任掌机PSV。

面临VB的惨烈失败，任天堂只能把所有的希望寄托在任天堂的新次世代主机Nintendo64身上。这是历史上第一款具有64位CPU的游戏主机，相对32位CPU而言，具有更高的主频，但由于64位的CPU当时的造价昂贵，N64的研发路上困难重重，加上山内固执己见的采用卡带系统，研发过于困难，难以容纳大型游戏，导致这款1995年年底公布的游戏主机直到1996年年中才在日本发售，足足晚了其他次世代机一年半的时间。尽管在这段时间里，任天堂遭逢VB主机失败，又恰逢Square等众多第三方软件厂商的众叛亲离，但依然展示了任天堂不俗的号召能力以及游戏软件研发能力。耗资达 4400 万美金，集结了任天堂最强大的研发阵容制作的《超级玛丽奥64》作为N64的首发游戏，为N64创下了10天之内销售50万台的记录，《超级玛丽奥64》也是Fami通上高达39分的游戏。尽管N64拥有远远超过SS和PS硬件的机能，也拥有Fami通历史上第一个满分的游戏《塞尔达传说时之笛》，但这依然改变不了N64在这场次世代主机竞争中被淘汰的局面。由于卡带本身制作成本远较CD-ROM高导致游戏软件比PS的价格贵，卡带的最大容量限制了软件制作商的发挥能力，同时，大量的优秀软件开发商远离任天堂的阵营而加入PS，由于任天堂独木难支而致使出现N64游戏软件的饥荒，这两个重要的原因致使N64开始的强劲势头在后来难以为继。21世纪初，这款历史上最后一部以卡带为媒介的主机在32位次世代机大战中以失败告终。尽管N64倒下了，但它依旧为游戏界创下了许多典范，在硬件方面，比如3D 模拟摇杆、震动装置以及四手柄接口成为日后游戏控制器的新一代标准，软件方面，任天堂显示了其游戏软件研发方面的强大的能力，在失去了大量第三方软件厂商的支持下，依然能够依靠自己的力量顶住来自各方的压力，在N64上开发出5款Fami通上的白金殿堂游戏，不得不令人佩服任天堂在游戏领域的王者气势。事实上，迄今为止历史上Fami通满分的20款游戏中（截至2012年），任天堂开发的游戏占8款，几乎达到了半数，只可惜这一次遭逢四面楚歌，一代霸王就此沦落，与其说这次是败在Sony上，还不如说是败在了各大厂商的联合作战下。

在20世纪90年代到21世纪初期，电视游戏领域处于游戏行业的绝对领导地位，街机市场逐渐沦陷，就在各大电视游戏厂商争夺这一领域的霸主地位的时候，随着个人电脑 PC 低下它昂贵的头，悄然走入寻常百姓家，以及电脑软件巨头微软帝国的兴起，电脑游戏业也如同雨后春笋般地发展起来。在1995年的E3游戏大展上，尽管Sony、Sega、任天堂占尽了风头，但一些新兴的电脑游戏软件厂商开始散发出耀眼的光芒，Westwood的《命令与征服》和暴雪《魔兽争霸 2》的依靠电脑硬件的独到优势，把及时战略游戏推到游戏的前沿，这两款游戏随即引发了全球即时战略游戏的狂潮。在国内，这两款游戏更是大小网吧中的必被备游戏，也让玩家首次领略到电脑游戏独特的魅力。从此，电子游戏领域就分裂TV Game 和PC Game 两大阵营，两大阵营的玩家也经常因为是 TVGame 的游戏精彩还是电脑游戏的好玩而爆发口水战。就单从娱乐性来看，TVGame 更加专业和精湛，而电脑游戏特别是电脑网络游戏，因为互联网的关系，拉进了玩家的距离，有着更为广大的群众基础，大家一起玩儿才是真的好。

1995年PC游戏展露锋芒之后，随后的几年中，在E3游戏大展上，电脑游戏逐渐具有了和电视游戏分庭抗争的能力，1996年的《雷神之锤》和《古墓丽影》拉开了3D电脑游戏的序幕，而暴雪也带了另一款巨作《星际争霸》。1997年E3展会上展出的《半条命》、《虚幻》、《古墓丽影》则凭借着功能强大的3D显卡在电脑上展示出了令人惊叹的3D效果，从而让电脑游戏正式进军 3D 时代，众多著名的游戏引擎开始锋芒毕露，比如《Doom》引擎（3D 游戏引擎的鼻祖）、《虚幻》引擎（半条命采用）等。

在经历了整整 6 代主机的失败之后，Sega 开始寻求合作伙伴，最开始选择了日本 Bandai （万代）公司，万代公司是日本最大的玩具制造商，也是世界顶级的玩具企业之一，万代公司的经典高达系列模型以及WS掌上游戏机风靡全球。正当外界普遍看好Sega和Bandai的联姻之时，Bandai却宣布取消了和Sega的合作，这使得Sega对自己的未来充满迷茫，于是，Sega只好把所有的希望全部放在SS 的后继者Dream Cast身上，谁知这一举措最终使得Sega 退出游戏市场，DC也成为了Sega的最后一部作品，同时，引狼入室，把电脑软件的巨头微软引入了电子游戏市场，形成了Sony、Nintendo和微软三足鼎立的时代。

图A-11是主要的第5代游戏主机。

图A-11 第5 代游戏主机

A.6 三足鼎立，一家独大

游戏史上的第6个重要的时代由Sega拉开序幕，从1998年直到2006年，总共经历了9个年头，在这9年中依然是Sony、Nintendo和微软三分天下，但Sony独大的局面。

Sega和Bandai的联姻计划破灭后，为了走出失败的阴影，1997年Sega选择了与当时的电脑业巨头微软合作，在 Sega 的下一代主机中采用微软的 Windows CE 操作系统。为了吸取SS对决PS时的机能不足的问题，Sega专门外聘IBM的高级工程师在美国成立了一个单独的开发组，在日本方面由佐藤秀树率领团队同时进行主机设计。最终，Sega采用了日本团队的设计方案，于1998年11月发布了这款世界上第一台带调制解调器具有联网功能的128位CPU的主机——Dream Cast，由单词Dream和Broadcast结合而成，意思是把梦带给大家。DC 采用了日立128位的高达200MHZ的CPU，FPU的运算能力比当时的顶级PC配置还高4倍，带有8MB的图形芯片具有每秒300万多边形的处理能力，这个处理能力是打开所有3D特效的处理速度，这款拥有梦幻般配置的硬件主机承载着 Sega 挽回败局的决心与希望，Sega 甚至打出了"Sega is dead, Long Life Dreamcast"，可见，Sega 对DC 抱有多大的希望。DC 在发售之初没有辜负梦幻主机的美名，主机发布后的1个小时内卖出了近8万台，首发的15万台很快售罄，但市场的需求远远超过了Sega的预计，加上DC的图形芯片厂商NEC的出货能力不足，导致这款当时顶级配置的主机经过了首发的热销后几乎从市场上遁形，这无疑是DC败走麦城的重大原因之一。与此同时，N64和PS上的游戏大作频频上演，致使几乎所有玩家把目光都集中在PS和N64上。在1999年Sony游戏发布会上，Sony公布了PS后续机种的计划，大肆宣称新机种的机能，随后的E3大展中更是宣传新主机的3D处理能力达到每秒处理7500万多边形，这一处理速度更是DC的近20 倍。事实上，这只是Sony玩的数字游戏，这一处理数据是在没有打开任何3D特效下得出的，据后来实测数据对比，在3D特效全开的情况下，Sony新机种的图形处理能力只有DC的2～3倍，不得不佩服Sony的吹嘘手段，正是因为Sony的这个数字噱头导致了大量准备购买DC的玩家等待翘首以待Sony的新品，可以说，这一次不仅老实的Sega被Sony给坑了，大量的玩家也被Sony给忽悠了。主机的供货不足加上Sony不厚道的吹嘘手法以及软件厂商的缺乏，Sega于2001年不得不被迫宣布DC停产，从此淡出家用机市场，转型为专门的软件生产商。和任天堂类似，Sega公司是一家软硬通吃的公司，Sega在游戏软件上也具有不俗的研发能力，事实上，Sega主机MD和SS上的几乎所有Fami通白金游戏以及销量最高的游戏都是Sega公司研发的。此后，Sega在现任主机霸主任天堂的Wii上发布的游戏《被封锁的涉谷》获得了Fami通的满分评价。虽然DC的历史只有短短的3年时间，但同样给我们留下了许多精品游戏，比如具有浓郁中国风格的神作《莎木》、超高人气作品《樱花大战3》、Femi通上唯一的一款满分的格斗游戏《灵魂能力》等。

Sega虽然倒下了，但在家用机市场中引来了来自电脑行业的巨人微软，在个人电脑操作系统领域夺得了绝对的霸主地位后，为了能够找到新的利润增长点，微软决定开拓电子娱乐行业。对于不熟悉的领域或者对手，微软一向采取的策略是先合作（顺便推广自己的操作系统或者搞到你的新技术）再收购（没有钱解决不了的事情，和我对着干，就把你买过来），在进军游戏领域的这一做法表现得淋漓尽致。首先和Sega 合作推广自己的Windows CE系统，在Sega的DC出师未捷后，微软又找上了Sony，希望Sony在PS的下一代主机中采用自己的Windows操作系统，遭到Sony婉拒。Sony在新一代主机中使用了微软的老对手Linux系统，这一做法无疑是对微软的挑衅，于是微软决定自行推出主机参与竞争。为了能够快速占领市场，微软提出以250 亿美元的价格收购任天堂公司并要求收购后的任天堂不得继续参与电子游戏事业的竞争。任天堂在北美的负责人荒川实（山内的女婿）认为任天堂正在走下坡路，应该趁目前还有价值的时候卖个好价钱，可山内却提出可以卖出任天堂的主机业务，但是掌机业务继续保留。微软虽然有钱，但也不是暴发户，明知道当时任天堂最赚钱的业务就是掌机业务，主机市场处于崩溃的边缘，怎么可能做出赔本的买卖？于是，微软断然拒绝了山内的提议。在任天堂知道微软进军游戏市场的决定后，山内对北美市场的高层做了调整，原来北美的高层几乎全被清理掉，而这群人则成为了微软开拓电游领域的第一代功臣。2000年9月，微软同时向北美和日本宣布开拓家用机市场的计划，宣布微软的新一代主机Xbox将于2001年发售，至此，微软代替了Sega公司，成为参与次世代机竞争的新的三巨头之一。2001年11月，微软在北美发布了其第一款游戏主机Xbox，参与新一轮主机的竞争，在纽约和旧金山举办了盛大的Xbox午夜首卖活动中，比尔·盖茨亲临纽约时代广场，并将第一部 Xbox 递给期待已久的玩家。相比同时代的主机，XBox 发售最晚，但由于微软的财大气粗，采用亏本卖硬件以及捆绑游戏软件的策略，在北美取得了不俗的成绩，截至2012年年底XBox的销量接近2500万元，超过了任天堂，成为这一轮主机竞赛的亚军。

如果说PS的成功把Sony推向了电子游戏业的王座，那么PS2则成了让Sony稳坐这一王座长达10年之久的守护神。2000年3月，Sony发布了PS的继承者PlayStation2，PS2采用Linux系统，拥有128位的CPU和所谓的每秒能处理7500万多边形的3D图形能力，采用了新一代的DVD作为游戏媒介，并且可以挂载硬盘连接网络，同时也可以直接运行PS的游戏，具有梦幻般硬件的配置并且集万千宠爱于一身，拥有众多一流的第三方的软件开发商的加盟无不显露出这款主机的王者风范。群众说好才是真的好，事实上，在PS2长达12年的生命中（2012年年底，Sony 宣布停止销售 PS2），创下了游戏史上的无数个神话，比如，最多软件厂商加盟的主机，最畅销的主机（截至2012 年，PS2的全球销量达到1.5 亿元），普及速度最快的主机（PS2的发售3天内，Sony公布其销售的PS2的主机数量高达98万台，随后的一年半的销量达到 2 000 万台），拥有Fami 通白金游戏最多的主机，第一台正式在中国大陆发行的游戏主机（行货，以前的主机都是水货）。

在PS2主机上也诞生了新派系列的游戏大作，比如极具血腥暴力美感和酣畅淋漓的打斗手感的动作游戏《战神》系列、Square和Disney合作的RPG游戏《王国之心》系列、带有日式风味的恐怖游戏《零》系列、被誉为动作游戏NO1的《鬼泣》系列等、经典水墨画游戏《大神》，至于其他的老牌经典游戏更是数不胜数，《最终幻想》系列、《勇者斗恶龙》系列、《生化危机》系列、《鬼武者》系列、《合金装备》系列、《三国无双》系列、《山脊赛车》系列、《铁拳》系列等。总之，在PS2的时代，其他的主机都活在PS2的阴影下，只能发出生不逢时的感叹。

在主机领域取得势如破竹的绝对胜利后，Sony 对于任天堂在掌机市场的霸主地位垂涎已久，为了报一箭之仇的Sony似乎想把任天堂赶尽杀绝，凭借着在移动影音领域的厚实的底蕴， Sony 于 2004 年 12 月 12 日发售了被誉为 21 世纪"WalkMan"的梦幻掌机 PSP（PlayStationPortable）。这款新型的掌机简直就是融合了众多高科技的艺术品，拥有比PS2还高的CPU主频， 4.3寸的液晶显示屏，专用的8CM的UMD光碟。从机能上看，此时任天堂的32位掌机Game BoyAdvance（GBA）和这款小型版 PS2 掌机根本就不是处在一个时代的产品，许多人已经急不可待的下了结论，PSP将会成为掌机市场新的领导者。事实上，直到2012年年底为止，PSP的销量都没有GBA高，更何况任天堂拿出和PSP竞争的根本就不是GBA，而是另一个创下了历史新高的神机——Nintendo Dual Screen（NDS），顾名思义，这是一款具有双屏幕的掌机系统，头一次采用双屏并结合触摸屏这一设计充分显示出了任天堂的创意无限，虽然 NDS 的机能远远低于PSP，但NDS与生俱来的游戏向下兼容性，加上触摸这一新颖的玩法，其最终的销量达到了惊人的1.5亿台，以微弱的优势甚至打败了PS2的销量，占据了历史上销量最高的游戏主机（数据截至2012年年底），不得不佩服任天堂对于游戏的理解。之后在主机市场上上演了同样的剧本，任天堂在失去江山10多年之后，借着对游戏深刻的设计理念，依靠新一代的主机Wii重新夺回了游戏主机市场。

PS2时代中，在PS2阴影笼罩下的任天堂可以说是举步维艰，在1999年的E3展会上任天堂只是宣布了N64的后继者代号为海豚，拥有比PS2更高的性能，其他的连具体的数据都没有公布，让大多数玩家只能画饼充饥。直到2001 年9月，N64的继任者Game Cube 才正式发售，比PS2 的发售晚了一年半，而此时的PS2 销量达到了2 000 万台，早已经把GC 甩了几条街，事实上直到PS2停产，GC的销量也才只有2 100 多万台，成为这次主机竞赛的垫底。对软件开发商一贯的"来者不拒，去者不留"的态度让任天堂彻底吃到了苦果，在N64时代任天堂就靠自己苦苦支撑，到了GC时代已经身心疲惫，看家大作《阳光玛丽奥》和Fami通满分大作《塞尔达传说风之杖》接连没有突破百万销量更是让任天堂颜面尽失，第三方厂商的信心严重受挫，一方面和任天堂虚与委蛇，另一方面与 Sony 暗送秋波。估计这次任天堂看到主机市场难以抗衡PS2，所以在匆忙发布了GC后，把宝都押在了掌机市场。这一次任天堂押对了，任天堂于2001年3 月发售了GB的接任者，一款拥有32位CPU的掌机Game Boy Advance（GBA），首次能在便携机上达到次世代主机效果，加上首发高达 25 款的游戏让这款掌机迅速成了掌机市场的新的王者，幸好GBA不负众望，否则辛苦打来的江山是否会毁于一旦都很难说。2002年8月，任天堂的领导者山内溥引退，同时宣布岩田聪为下一任任天堂的总裁，固执的山内在发布会上依旧毫不避讳地指出Sony的这次胜利只是暴发户的胜利，随时都有可能失败，当然，在外界看来，这也是不过是山内的一句气话罢了。岩田聪上台之后，任天堂也意识到游戏市场决定性的胜利并非主要取决于硬件，而在于游戏软件上，所以，任天堂一改往日对第三方软件厂商的傲慢态度，采用更加谦和的态度与第三方厂商进行合作。尽管任天堂采取的一系列措施并没有挽回N64、GC失败的定局，但为任天堂的下一步的复苏计划奠定了基础。

图A-12展示了第6代游戏主机以及同时代掌机。

图A-12 第6 代游戏主机及同时代掌机

新一轮的主机竞赛中，PS2 以绝对的优势获得了胜利，首次参与次世代机竞争的微软则取得了第2的成绩，而任天堂则有日薄西山的趋势。这一轮竞赛中，欧美公司不仅在游戏硬件领域占有了一席之地，在游戏软件方面更是开始初露峥嵘。一手由微软提携起来的公司 Bungie公司，在 Xbox 平台上发售的第一人称设计游戏《光晕》（光环）受到广泛好评，成为 Xbox主机上首个销量超过100万的大作，累计销量达到600多万，其系列作品都获得了不俗的成绩。Rockstar公司制作了在PS2上销量最大的游戏系列《侠盗猎车手》，每部作品都有超过1 000万以上的销售量。欧美一些知名电脑游戏软件厂商比如Ubisoft、Rockstar、EA（Electronic Arts）、Activision 等纷纷在家用机上发表作品，虽然在日本市场不太受欢迎，但是在欧美则受到追捧，由于欧美市场远大于日本市场，所以在收益上也超过了许多日本老牌游戏开发商。反观诸多的日系厂商，一方面由于东西方文化的差异导致日系的游戏在全球最大的欧美市场并不受欢迎，另一方面Sony提高了权利金的标准导致许多日系厂商难以维持收支平衡，许多厂商处于崩溃的边缘，甚至连 Square这样的大厂商都不能幸免。在斥巨资打造全3D电影《最终幻想：灵魂深处》遭受票房惨败后，让Square陷入了无法自拔的财务危机，为了摆脱这种局面， Square寻求新的合作伙伴，于2003年4月1日和Enix合并为Square-Enix公司，合并后保留Enix 公司结构，而原 Square 公司结构则并入 Enix。事实上，与其说是合并，实质上是 Enix收购了Square，此后引起了一系列公司合并的热潮，Sega和Sammy于2004年7月合并成立Sega-Sammy公司，Bandai和Namco于2005年9月29日成立Namco-Bandai公司。不管怎么说，这一系列的日系游戏软件巨头的合并无疑说明了随着欧美在游戏软件领域的日渐强大，单靠自身的力量已经难以抵御，只有通过合并后加强研发实力才能够参与全球性的游戏行业的竞争。

A.7 王者归来，百家争鸣

凭借着雄厚的资金背景，在第6代游戏主机竞争中获得亚军的微软意图趁胜追击，率先于2005年11月推出了XBox的后继者——XBox360，拉开了第7代游戏机时代。XBox360极其华丽的流线型外观比XBox笨重的外形让人更容易亲近。硬件方面，360采用了IBM的PowerPC高达3.2GHZ的CPU以及ATI专为XBox360量身定做的Xenos显卡，这些无疑为XBox在游戏中进行高画质的渲染奠定了条件。同时，XBox360采用了无线手柄装置，可供四人同时游玩，加上Xbox Live的在线服务功能，玩家可以直接连接Internet和其他玩家进行互动，也可以从服务平台下载最新游戏的试玩版和影音媒体。这一切无不显示出了XBox360对玩家的体贴，加上微软的低价倾销策略，使得这款新型的 Xbox 很快得到了玩家的认可。另一方面，微软凭借在软件行业巨大的号召力迅速集结了一大批诸如动视、艺电、育碧等欧美的顶级软件厂商。此时，由于日本经济和电子游戏市场的持续低迷，加上微软不惜重金拉拢日系厂商，使得许多日系软件厂商也纷纷支持Xbox360转战欧美市场，例如Square-Enix公司一直在PS系列主机独占的著名RPG游戏《最终幻想》也登录XBox主机。Xbox360首发的一系列大作游戏《使命召唤2》、《死或生4》、《极品飞车之最高通缉令》、《FIFA2006》、《雷神之锤4》、《横冲直撞之复仇》等更是让玩家看到微软的诚心。截至2012 年年底，这款具有靓丽外型的主机全球销量达到了7 200多万台，这一记录远远超过了Xbox全球才2 400万台的记录，也超过了Sony的新一代主机PS3，稳稳端坐亚军的宝座。

看见微软率先推出XBox360后，Sony如坐针毡，在微软公布Xbox360主机后没过几天， Sony就高调揭开了PS2的继承人——PS3的神秘面纱。Sony依然玩起了PS2时代对付DC的手段，大肆吹嘘PS3的机能，鼓吹PS3采用了由IBM、Sony和东芝共同研制的Cell芯片，该芯片的处理能力将是Xbox360的2倍，甚至还忽悠到Cell的处理能力将达到奔腾4处理器的1 000倍，凭借Cell的处理能力能够模拟地球，这一说法至今都是一个笑柄。除此之外，PS3将会采用新一代的蓝光光碟作为游戏的存储媒介，其容量达到了普通DVD的6倍。Sony在2005年公布了PS3的硬件指标后，遇到其使用的蓝光技术和HDMI规格冲突的问题以及其中的图形芯片遭遇设计问题，直到2006年11月才发布，相比Xbox360晚了足足一年之久。凭借着统治两个游戏机时代的强大底蕴，一开始PS3气势如虹，日本首批的10万部以及北美的40万部被抢购一空，在北美甚至还发生排队购买PS3而推挤受伤以及持枪抢劫PS3的事件。诚然，PS3的硬件机能在这一时代是最高的，采用的蓝光光碟也是容量最大的，但高科技换来的代价是主机和游戏成本的居高不下，加上产能不足、发售时间太晚导致最后在全球的总销量比XBox360稍低而在这一轮主机竞赛中垫底。PS3上也留下了许多经典的游戏大作，比如耗资1亿美金、历时6 年打造的经典赛车系列游戏《GT5》、Fami 通上的满分大作游戏《潜龙谍影 4》、顽皮狗专为PS3 倾力打造的拥有好莱坞电影级别表现力的《神秘海域之德雷克的宝藏》、PS 系列主机忠实的伴随者《山脊赛车》系列和《铁拳》、《VR战士》系列等。

就在XBox360和Sony比拼硬件机能的时候，任天堂也随后在发布会上公布了新一代的主机。相比Sony华而不实的高调，这一次的任天堂相当之低调，在发布会上岩田只是宣称新主机的代号叫"革命"，强调这款主机将会让玩家对游戏有新的认识。除了展示出了新主机的外观，没有任何硬件相关的数据，甚至连一款像样的Demo都没有，这款充满神秘气息的主机让玩家充满了无限的遐想和期待。在 Sony 发售 PS3 没过几天，任天堂低调地发布了新一代的游戏主机 Wii，和主机低调的发布截然相反的是引来了席卷全球的购买狂潮，在发布后的短短两个月内就销售了300万台，即使任天堂做好了充分的市场准备依然引起了全球Wii缺货的恐慌。

比硬件，Wii 的硬件机能在本次竞争的三大主机中垫底，其 CPU 的处理能力甚至达不到XBox的三分之一，图形处理能力也只有Xbox的一半左右；拼软件，自从任天堂的几番失利后，第三方的软件商都找到了其他的靠山，任天堂几乎只能自食其力了。究竟是这款主机有着什么样的魅力让全球的人们为之疯狂呢？这一切得归功于任天堂对于游戏独到的理解和别出心裁的创意，Wii使用了独有的动作感应控制器Wii Remote，装备了Wii Remote 的Wii能够捕捉玩家所做出的动作。这一独特的硬件装置使得玩家把双手从游戏中解脱出来，依靠Wii的特殊控制器，玩家可以把身体的任一部分参与到游戏中去，让玩家真正体验到和游戏中的人物的互动效果。比如，玩家可以把Wii控制器当高尔夫球杆，当玩家做出挥球杆的动作，游戏的人物也会完成同样的动作，你甚至可以在Wii的呼啦圈游戏中摇晃你的身体体验虚拟物体的感觉。正是这种不再专注于游戏机的高效能开发，而是重新塑造玩家与游戏间的互动关系的新的玩法吸引了大量各个年龄阶层的用户群，真正在引领游戏领域的新一轮"革命"。截至目前，Wii的全球销量已经接近1亿台，借助掌机领域的新的霸主NDS和具有革命性主机Wii的双丰收，任天堂终于夺回了丢失了10年的电子游戏王座，上演了一幕王者归来的传奇。

图A-13展示了第7代游戏主机。

图A-13 第7 代游戏主机

第7个游戏机时代已经过去，新一代的游戏机争夺战即将展开。江山代有人才出，各领风骚数十年，如今的游戏市场也不再是电视游戏独大的时代，参与新时代竞争的除了传统的电视游戏三巨头，任天堂的WiiU、Sony的PS4以及微软的Xbox720外，还有拥有庞大移动市场群体的手机市场、平板市场，以及电视游戏永远也无法参与竞争的网络游戏市场等，在这充满变数以及高科技迅速发展的时代，我们将拭目以待游戏世界新的革命。

附录B 游戏类型

在游戏发展史上各种游戏因为玩法的不同可以划分成很多不同种类的游戏，但是在目前游戏越来越庞大复杂的今天，许多游戏会融合多种玩法元素导致分类不是非常明显，大体上按照游戏的玩法和内容分为以下几种类型（内容主要参考维基百科）。

B.1 射击游戏

缩写为 STG（Shooting Game），可以算是所有游戏类型的鼻祖了，玩家可以操作主角（通常是飞机）发射子弹击毁目标。这一类型的游戏主要以飞行射击类为主，第一个有 交互意义的 Space War 宇宙战争为起源，街机上的主流游戏之一，典型的游戏有《太空侵略者》、《打击者》系列、《战国》系列、《19××》系列、《东方》系列、《雷电》、《沙罗曼蛇》等。

早期的射击类游戏主要是2D飞行射击类，随着3D技术的日益成熟，出现了大量经典的第一人称射击类游戏，也称为FPS（First-person Shooter）游戏。这类游戏主要以显示屏充当玩家的眼睛，玩家以第一视角在游戏世界中摧毁目标完成任务，以《德军总部3D》为起源，典型的游戏有《Quake》系列、《Doom》系列、《虚幻》系列、《Half Life》系列、《使命召唤》系列、《荣誉勋章》系列、《三角洲部队》系列、《007黄金眼》等。

除了第一人称的 FPS 游戏，还有一种玩家是以第三人称为视角的射击游戏，也称为 TPS （Third-person Shooter）游戏。典型的游戏有《魂斗罗》系列、《合金弹头》系列、《马克思·佩恩》系列、《生化危机》系列、《恐龙危机》系列、《战争机器》系列、《合金装备》系列等，事实上，现在很多3D类型游戏可以进行视觉切换。

还有一种以光线枪作为游戏设备，通过光线枪和游戏进行交互，射击游戏画面中的目标，历史上第一代家用机"奥德赛"就已经配备，典型的游戏有FC上的《打鸭子》、《打飞碟》，街机上的《VR战警》系列、《死亡之屋》系列等。

B.2 动作游戏

缩写为ACT（Action Game），可以说这类游戏最为丰富，在这类游戏中，通常会设定许多关卡，而玩家主要依靠反应能力和游戏中给主角设计的各种丰富的动作技巧，通过设定的各项关卡而达到目的地。事实上，可以说现在的大多数类型的游戏融合了ACT的要素，所以不能严格地进行区分，比如像《魂斗罗》、《合金弹头》等，既包含了动作元素，也融合了射击元素，《GTA》系列甚至还包括了赛车的元素。

具有代表意义的动作游戏有《超级玛丽》系列、《大金刚》系列、《双截龙》系列、《洛克人》系列、《恶魔城》系列、《波斯王子》系列、《银河战士》系列、《圆桌骑士》、《恐龙新世纪》、《名将》、《鬼泣》系列、《三国无双》系列、《战神》系列等。

B.3 角色扮演游戏

缩写为 RPG（Role Playing Game）游戏，这类游戏就像一部故事书一样，具有故事情节，而玩家就需要扮演故事中的人物，伴随着游戏进行而成长。由于东西方文化的差异，导致了东西方文化对 RPG 游戏的诠释不同，以日式风格为首的 RPG 更注重人物的刻画和游戏的情节，而欧美更注重的是自由度更高的游戏世界观。欧美人永远无法理解在日式RPG中，为什么永远都是未成年儿童当主角，拿着比自己还高的武器到处砍怪拯救世界（简直就是虐待儿童），正如日本人也无法理解为什么欧美的 RPG 游戏中的主角永远都是比较粗糙难看一样。当然，对于在游戏市场全球化的今天来说，这两种风格的RPG有相互融合的趋势。

在RPG游戏的发展过程中，后来又融合了动作元素以及策略元素，这就在RPG游戏上又分化出了ARPG（Action Role Playing Game）动作角色扮演游戏以及 SRPG（Strategy Role- Playing Game）策略角色扮演游戏。随着 Internet 网络的兴起，MMORPG（Multiplayer Online Role-Playing Game）多人在线角色扮演游戏也逐渐流行。

典型的日式风格的RPG游戏有《最终幻想》系列、《勇者斗恶龙》系列、《英雄传说》系列、《格兰蒂亚》系列、《伊苏》系列、《永远的伊苏》系列、《光明与黑暗》系列、《永恒传说》系列、《幻想传说》系列等。

典型的欧美风格的RPG游戏有《博得之门》系列、《魔法门》系列、《无冬之夜》系列、《地牢围攻》系列、《上古卷轴》系列、《暗黑破坏神》系列、《龙与地下城》等。

对于国人来说，经典的RPG非武侠类莫属，代表作有《仙剑奇侠传》系列、《轩辕剑》系列、《剑侠情缘》系列、《绝代双骄》系列、《金庸群侠传》等。

MMORPG 代表游戏有《网络创世纪》、《魔兽争霸》系列、《暗黑破坏神》系列、《永恒之塔》系列、《天堂》系列、《上古世纪》等。

B.4 模拟游戏

缩写为 SLG（Simulation Game），用电脑模拟现实生活中的各种环境的游戏都可以称为模拟游戏。这类游戏含义比较广泛，又可以细分为战争模拟、经营模拟、人生模拟、交通工具模拟、养成模拟等。

战争模拟类游戏，通常所指即我们常说的战略游戏（策略游戏），这类游戏主要用来模拟实时的大规模的战争游戏，玩家可以同时操作多个精灵对象对目标进行攻击。在战略游戏中，一般需要玩家给游戏中的角色下达决策（通常是指令）后，游戏角色根据玩家的决策进行活动以达到游戏目的。根据决策时机的不同，战略游戏又分为回合制战略游戏和即时战略游戏。回合制游戏典型的特征是，只有当玩家或者敌方结束一个回合行动之后，才由另一方进行决策行动，这类游戏的代表作有《梦幻模拟战》系列、《机器人大战》系列、《超时空英雄传说》系列、《炎龙骑士团》系列、《火焰之纹章》系列、《三国志》系列、《信长之野望》系列、《大战略》系列等。

即时战略游戏，缩写为（Real-Time Strategy）游戏，和回合制不同的是，即时战略游戏不是采用回合的方式，而是采用真实的时间方式进行游戏，以《沙丘魔堡》为起源，其他比较经典的RTS游戏有《命令与征服》、《帝国时代》系列、《星际争霸》系列、《横扫千军》系列等，现在很多RTS游戏融入了一些RPG要素，比如《魔兽争霸》。

经营模拟类游戏，这类游戏中玩家通常需要模拟经营某一个现实世界中的行业，经典的游戏有《模拟城市》、《我是航空管理员》、《铁路大亨》、《大富翁》、《牧场物语》、《主题医院》等。

人生模拟类游戏，玩家在电脑给定的模拟时间段中模拟人成长的过程，比较经典的游戏代表有《模拟人生》系列。

交通工具的模拟游戏，通常这类游戏中会使用电脑模拟一个交通工具真实的环境，电脑屏幕中会显示成交通工具驾驶室内的场景，玩家可以通过电脑控制交通工具，典型的游戏有《微软模拟飞行》系列、《电车GO》系列等。

模拟养成类游戏，这类游戏中，玩家需要在游戏中培养特定的对象（人、动物或者其他）达到目的，典型的游戏有《心跳回忆》系列、《美少女梦工厂》系列、《明星志愿》系列、《足球经理》系列、《任天狗》系列、《孢子》等。

B.5 冒险游戏

英文缩写为 AVG（Adventure Game），在这类游戏中，玩家通常需要完成冒险的过程，早期的时候由于技术的限制，通常是文字类游戏为主，后来也融合诸如动作、恐怖、悬疑元素等。这类的代表游戏有《猴岛小英雄》、《印第安那琼斯》、《神秘岛》系列、《寂静岭》系列、《古墓丽影》系列、《零》系列、《逆转裁判》系列等。

B.6 格斗游戏

英文缩写为 FTG（Fighting Game），这类游戏中，由玩家控制人物和敌人（电脑或者其他玩家）分别位于屏幕两边进行对战，这类的代表游戏有《街头霸王》系列、《拳皇》系列、《侍魂》系列、《VR快打》系列、《生或死》系列、《灵魂能力》系列等。

B.7 竞速游戏

英文缩写为RCG（Racing Game），玩家模拟赛车（或者摩托车，飞机等交通工具）进行游戏竞技，典型的游戏有《玛丽赛车》系列、《F-Zero》系列、《GT》系列、《极品飞车》系列、《Sega拉力》系列、《摩托英豪》系列、《山脊赛车》系列、《跑跑卡丁车》等。

B.8 体育游戏

英文缩写为SPG（Sport Game），体育竞技类游戏，使用电脑模拟各种体育运动，比如《热血高校》系列、《FIFA》系列、《实况足球》系列、《NBA Live》系列等。

B.9 牌桌游戏

英文缩写为 TBG（Table Game），使用电脑模拟各种桌子或平面上玩的游戏，主要是以棋牌类为主，代表游戏有《拖拉机》、《红心大战》、《麻将》、《弹珠台》和《中国游戏中心》中的各类在线棋牌类游戏等。

B.10 益智类游戏

英文缩写为PUZ（Puzzle Game），侧重益智、解谜类的小游戏，代表性的游戏有《俄罗斯方块》、《泡泡龙》、《推箱子》、《连连看》、《大家来找碴》、《脑力锻炼》和各类拼图游戏等。

B.11 音乐游戏

英文缩写为MSC（Music Game），以培养玩家对音乐的感知和节奏而设计的游戏，典型的游戏有《狂热节拍》、《劲舞团》、《太鼓达人》、《DJ MAX》、《吉他英雄》等。

B.12 其他类型游戏

英文缩写为ETC（etc Game），这类游戏没有上述游戏的典型特征，和玩家互动较少，所以被归为其他类型，典型的游戏有《恐怖惊魂夜》。

随着技术的进步、游戏的题材内容越来越庞大丰富，游戏类型之间的内容出现了交叉融合的现象，单一具有某种特征的游戏越来越少，所以以上的分类仅供参考。

图书在版编目（CIP）数据

HTML5游戏编程核心技术与实战/向峰编著.--北京：人民邮电出版社，2013.10

（游戏设计与开发技术丛书）

ISBN　978-7-115-32701-7

Ⅰ.①H…　Ⅱ.①向…　Ⅲ.①超文本标记语言—程序设计　Ⅳ.①TP312

中国版本图书馆CIP数据核字（2013）第181387号

内容提要

这是一本全面介绍HTML5游戏编程的书，在详细阐述HTML5的核心技术基础上，深入讲解游戏的运行机制，剖析游戏的核心——游戏引擎的细节内幕，并以大量完整的游戏实践开发案例为指导，逐步讲解游戏开发中常用的各种技术和方法。

全书一共11章，先后介绍了HTML5的新特性、HTML5中的canvas绘图技术、多媒体技术、游戏运行机制及游戏渲染引擎的开发、HTML5中的网络通信基础和Node.js框架、游戏中常用的算法技巧、物理引擎Box2D创建物理游戏、使用CSS3创建游戏，然后给出一个飞行射击游戏的综合案例。此外，在最后两章分别通过Node.js结合socket.js框架实现了两个游戏。每一章的内容既包括丰富的理论知识，又给出实战性极强的案例。

本书适合有一定HTML和JavaScript语言基础，对HTML5游戏编程有浓厚兴趣的Web前端开发工程师阅读，同样适合有一定的HTML5游戏开发基础的HTML5游戏开发的工程师阅读。

◆编著　向峰

责任编辑　杨海玲

责任印制　程彦红　杨林杰

◆人民邮电出版社出版发行　　北京市崇文区夕照寺街14号

邮编　100061　　电子邮件　315@ptpress.com.cn

网址　http://www.ptpress.com.cn

北京天宇星印刷厂印刷

◆开本：800×1000　1/16

印张：24

字数：558千字　　2013年10月第1版

印数：1-3500册　　2013年10月北京第1次印刷

定价：59.00元

读者服务热线：(010)67132692　印装质量热线：(010)67129223

反盗版热线：(010)67171154

Table of Contents

第1章 游戏和HTML5初探

第2章 Canvas绘图

第3章 多媒体元素

第4章 制作2D游戏引擎

第5章 网络通信基础

第6章 游戏常用算法

第7章 Box2D引擎和物理游戏

第8章 使用CSS3开发游戏

第9章 编程实战之太空战机

第10章 编程实战之《你画我猜》

第11章 编程实战之《疯狂的小鸟》

附录A 游戏的历史

附录B 游戏类型

版权