我打算公开该游戏的技术背景,及其如何在多种网络技术基础之上构建整个项目。应用在该游戏中的技术有:Node.js,express(静态内容服务),Socket.io(处理客户端和服务器端关于小球往复运动的通讯),Sylvester.js(物理引擎的矢量库)和jQuery。
那什么是VeloMaze呢?VeloMaze是被许多点状恐龙(迅猛龙)占据的迷宫。迅猛龙希望小球能一直在迷宫中移动。由于迷宫的连续性,它可 以说是没有终点的。但是每当你通过一级关卡,就会给你之后的玩家造成更多麻烦,因为他(她)会获得另一个小球!是不是很有趣?这就是迷宫中的生活。
这个游戏非常适合那些在同一个地方,而且每个人都有手机的团队。这在当今是很常见的。这里还有一段解说游戏系统要求的视频。
系统运行最重要的条件就是加速计。加速计是测量加速度的设备。带有加速计的设备通常返回重力的角度或者重力的矢量数据。这在某些浏览器中有可能做到,比如在下列网贴中所提及的:
- iPhone和iPad 4.2版的Safari:加速计、网页接口和更好的HTML 5支持
- 有什么在网站和web应用程序中运用HTML 5加速度计的好例子吗?
- 利用iOS设备提供基于HTML 5加速计的游戏控制
从描述系统要求的视频中可以注意到,某些笔记本电脑中也配有加速计。相当多新式的MacBook Pro笔记本为防止跌落时造成硬件损伤也安装了加速度计(我那台2009年买的笔记本中就安装着一个)。我觉得以笔记本旋转为基础的游戏开发领域目前还是 少有人涉足的地带!下面的图表演示了应用程序架构在上层是如何搭建的。
游戏本身的开发相当容易,但全面支持所有的浏览器和加速度计组合需要做更多的工作,而我们的小组只拥有48小时的时间。因此,有些测试我们是没有做的,比如对最新版Android系统的测试;但是我惊喜的发现,我们的游戏在其中却运行的非常好!然而运气只是成功的一部分。在下面的篇幅中,我打算解析游戏玩法的编写,并解释究竟怎样使该游戏具有可玩性。
读取加速度计数据非常简单,不过标准的缺失使得该过程比预想的更加难以实现。首先,我们快速调查了小组内现有的各种不同的平台和浏览器组合,为适应各种组合方式,编写了如下代码:
- /* 这里检查游览器是否支持DeviceOrientationEvent事件(链接到W3C)。*/
- if (window.DeviceOrientationEvent) {
- window.addEventListener('deviceorientation', function(e) {
- // 我们从事件“e”中获取角度值并转化成弧度值。
- leftRightAngle = e.gamma /90.0*Math.PI/2;
- frontBackAngle = e.beta /90.0*Math.PI/2;
- }, false);
- } else if (window.OrientationEvent) { //另一个选项是Mozilla版本同样的东西
- window.addEventListener('MozOrientation', function(e) {
- //在这里将长度值当做一个单位,并转换成角度值,看起来运行的不错。
- leftRightAngle = e.x * Math.PI/2;
- frontBackAngle = e.y * Math.PI/2;
- }, false);
- } else {
- // 自然地,没有浏览器支持的大多数人会获取这个。
- setStatus('Your device does not support orientation reading. Please use Android 4.0 or later, iOS (MBP laptop
- is fine) or similar platform.');
- }
结果是,代码可以在版本较新的Chrome中正常运行,也有人反馈说说它也可以运行在较新版本的iOS上的Safari浏览器当中(但是我手头上的 Safari并不支持)。我决定不再试图寻找那种能读取所有可能用的浏览器中加速度计数据的普适性解决方案,因为现实是我们在Node淘汰赛的编码环节中 个只有48小时的时间,而当时游戏的架构还没有完成。
我决定使用Sylvester,它是一个碰撞检测的向量和矩阵数学库。其实我也可以使用Box2D JS来节省时间,但是由于有过Sylvester的使用经验,并且所需的碰撞检测比较简单,我还是决定使用Sylvester。检查小球是否落到洞里去的代码如下所示:
- function checkBallHole(ball, hole, dropped) {
- // 用Sylvester定义洞和求的位置为矢量对象
- var holeVector = $V([hole.x, hole.y]);
- var ballVector = $V([ball.x, ball.y]);
- // 在Sylvester中用向量简单的计算距离
- if (ballVector.distanceFrom(holeVector) < hole.r) {
- // 用球的位置作为变量执行回调函数
- dropped(ballVector);
- }
- }
所以事实上这里没有什么复杂的:如果你的小球的中心位于洞内,那么就会触发“dropped”的函数。这段代码在每帧运行一次,那么以前开发过游戏 的朋友都知道,这种实现方式可能会造成小球在这一帧内飞跃洞穴而没有掉进去。然而,在日常生活中我们知道,如果你用足够快的速度将小球推向洞穴,它是可以 滑过而不掉落的,所以这不是个问题。
这个游戏中也有墙体,所以碰撞检测也是必须要做的。Sylvester提供了一种目标与计算线状对象的放发,我用的就是这个。简单的代码如下:
- // 计算球和墙壁碰撞时的冲击矢量数据
- function impactBallByWall(ball, wall) {
- var ballVector = $V([ball.x, ball.y]);
- // 定义墙体为线段(x1,y1) (x2,y2)
- var wallSegment = Line.Segment.create(
- $V([wall.sx, wall.sy]),
- $V([wall.dx, wall.dy]));
- // 计算墙与球的最近点(几乎就要撞上的那个位置)
- var collisionPoint = wallSegment.pointClosestTo(ballVector)
- .to2D(); // needed by sylvester to convert 3D to 2D vector
- //sylvester将矢量数据从3D转化成2D所需的变量,然后看这个距离在当前框架内为多少(并不是在两个框架之间差距多少)
- var dist = collisionPoint.distanceFrom(ballVector);
- //天真的假设碰撞只发生在球和墙的距离小于球的半径的情况下
- if (dist < ball.r) {
- //调整到一个合适的值。较大的逆质量值意味着更大的影响(和较小的质量)
- var inverseMassSum = 1/100.0;
- //从球心到碰撞点的向量
- var differenceVector = collisionPoint.subtract(ballVector);
- var collisionNormal = differenceVector.multiply(1.0/dist);
- // 球陷下去的部分相当于在墙内
- var penetrationDistance = ball.r-dist;
- //碰撞时球的速率
- var collisionVelocity = $V([ball.vx, ball.vy]);
- // 从点属性中我们获得冲击速度
- var impactSpeed = collisionVelocity.dot(collisionNormal);
- if (impactSpeed >= 0) {
- // 计算冲击量。运动能量在每次碰撞是以2-1-0.4=0.6的倍率递减
- var impulse = collisionNormal.multiply(
- (-1.4)*impactSpeed/(inverseMassSum));
- //冲击只会作用在球上,因为墙被设计为固定的
- var newBallVelocity = $V([ball.vx, ball.vy]).add(
- impulse.multiply(inverseMassSum));
- //把值传回原来的对象
- ball.vx = newBallVelocity.e(1);
- ball.vy = newBallVelocity.e(2);
- }
- }
- }
在实现小球和墙体的碰撞过程时我做了许多并非真实的假设(但是跟现实足够接近)。首先,墙体的厚度为零(而不是实际上的5像素),而且,我没有计算两帧之 间发生了什么。很明显,这会导致游戏中球体有能力穿越墙体。通过创建球体在不同帧之间的运动线段并找出球体三角与墙体之间是否有交叉,就很可以容易的测试 到是否会发生碰撞。那么我们就必须要计算小球和墙体发生碰撞的位置。在上文的代码段中,这个位置数据就存在变量“collisionPoint”内(见下图)。
我很喜欢Ganvas和WebGL,但是我们计划使用DOM和jQuery来做渲染,因为我们除了制作球体滚动之外,不需要任何Ganvas和WebGL 的特效(如果这样实现,其实是很优雅的,真可惜)。使用DOM渲染的场景在缩放时有点生硬,但它很容易实现。我写了下面的函数用于绘制游戏中的子画面。
- //设置DOM元素属性以反映sprite对象
- setElementPosition: function(element, sprite) {
- // 同步sprite维数
- sprite.width = (maze.getSquareWidth() * sprite.r * 2);
- sprite.height = (maze.getSquareHeigth() * sprite.r * 2);
- var x = sprite.x;
- var y = sprite.y;
- /* 在绝对定位中计算样式属性left和top的值
- * 从而确保点(x,y)在sprire的中心位置(使距离计算更加简单)
- */
- var newLeft = (x * maze.getSquareWidth() - element.width() / 2.0);
- var newTop = (y * maze.getSquareHeigth() - element.height() / 2.0);
- // 避免sprite因为受到传感器持续输入的影响而产生的颤抖
- // 通过一个阈值判断是否显示球在屏幕上的移动。
- // 这是一个相当大的阈值,对于某些设备来说应该选择较小的值。
- if (thresholded(element.css('left') - newLeft, 5) !== 0) {
- //设置DOM元素的x坐标位置
- element.css('left', parseInt(newLeft) + 'px');
- }
- if (thresholded(element.css('top') - newTop, 5) !== 0) {
- //设置DOM元素的y坐标位置
- element.css('top', parseInt(newTop) + 'px');
- }
- //设置DOM元素的大小。
- element.css('width', sprite.width + 'px');
- element.css('height', sprite.height + 'px');
- // 球状 DOM元素包含许多层(所有的div),所以重置所有层。
- element.find('div').each(function () {
- $(this).css('width', sprite.width + 'px');
- $(this).css('height', sprite.height + 'px');
- });
- // sprite位置的调试信息。通过点击‘enter’显示调试信息。
- element.find('.location').html('('+parseInt(sprite.x*10)/10.0+','+parseInt(sprite.y*10)/10.0+')');
- },
我做了一个根据视角实时缩放的功能,因此在每个框架中的宽度和高度都是计算得到的。很不幸在游戏中没有体现出这点,因为我们尝试编程控制浏览器旋转失败了(没有用于此项功能的接口,所以这还需要破解)。所以我们最后决定,通知用户关闭手机浏览器的旋转功能,如下图所示:
所有的加速度计数据的读取,物理引擎的运行和DOM渲染都被归拢到一个主循环中了。我将所有的主循环的代码放置到函数“update”中并且每100毫秒运行一次(我知道这不够频繁,但是它在我的设备上运行的很好,所以就暂时忽略这个设定值吧),像这样:
- window.setInterval(function() { update(); }, 100);
客户端的所有源代码可以点击这里获取。
顺便提一句,我对于新式的视网膜MacBook Pros非常失望,它没有加速计(就像我们某位玩家提到的),因为它们的SSD驱动器没有可以移动的部件!所以也许以笔记本旋转为基础的游戏看起来要到此为止了。