使用HTML5開發(fā)體感游戲:VeloMaze的開發(fā)案例
我打算公開該游戲的技術(shù)背景,及其如何在多種網(wǎng)絡(luò)技術(shù)基礎(chǔ)之上構(gòu)建整個(gè)項(xiàng)目。應(yīng)用在該游戲中的技術(shù)有:Node.js,express(靜態(tài)內(nèi)容服務(wù)),Socket.io(處理客戶端和服務(wù)器端關(guān)于小球往復(fù)運(yùn)動(dòng)的通訊),Sylvester.js(物理引擎的矢量庫)和jQuery。
那什么是VeloMaze呢?VeloMaze是被許多點(diǎn)狀恐龍(迅猛龍)占據(jù)的迷宮。迅猛龍希望小球能一直在迷宮中移動(dòng)。由于迷宮的連續(xù)性,它可 以說是沒有終點(diǎn)的。但是每當(dāng)你通過一級關(guān)卡,就會(huì)給你之后的玩家造成更多麻煩,因?yàn)樗ㄋ?huì)獲得另一個(gè)小球!是不是很有趣?這就是迷宮中的生活。
這個(gè)游戲非常適合那些在同一個(gè)地方,而且每個(gè)人都有手機(jī)的團(tuán)隊(duì)。這在當(dāng)今是很常見的。這里還有一段解說游戲系統(tǒng)要求的視頻。
系統(tǒng)運(yùn)行最重要的條件就是加速計(jì)。加速計(jì)是測量加速度的設(shè)備。帶有加速計(jì)的設(shè)備通常返回重力的角度或者重力的矢量數(shù)據(jù)。這在某些瀏覽器中有可能做到,比如在下列網(wǎng)貼中所提及的:
- iPhone和iPad 4.2版的Safari:加速計(jì)、網(wǎng)頁接口和更好的HTML 5支持
- 有什么在網(wǎng)站和web應(yīng)用程序中運(yùn)用HTML 5加速度計(jì)的好例子嗎?
- 利用iOS設(shè)備提供基于HTML 5加速計(jì)的游戲控制
從描述系統(tǒng)要求的視頻中可以注意到,某些筆記本電腦中也配有加速計(jì)。相當(dāng)多新式的MacBook Pro筆記本為防止跌落時(shí)造成硬件損傷也安裝了加速度計(jì)(我那臺(tái)2009年買的筆記本中就安裝著一個(gè))。我覺得以筆記本旋轉(zhuǎn)為基礎(chǔ)的游戲開發(fā)領(lǐng)域目前還是 少有人涉足的地帶!下面的圖表演示了應(yīng)用程序架構(gòu)在上層是如何搭建的。
游戲本身的開發(fā)相當(dāng)容易,但全面支持所有的瀏覽器和加速度計(jì)組合需要做更多的工作,而我們的小組只擁有48小時(shí)的時(shí)間。因此,有些測試我們是沒有做的,比如對最新版Android系統(tǒng)的測試;但是我驚喜的發(fā)現(xiàn),我們的游戲在其中卻運(yùn)行的非常好!然而運(yùn)氣只是成功的一部分。在下面的篇幅中,我打算解析游戲玩法的編寫,并解釋究竟怎樣使該游戲具有可玩性。
讀取加速度計(jì)數(shù)據(jù)非常簡單,不過標(biāo)準(zhǔn)的缺失使得該過程比預(yù)想的更加難以實(shí)現(xiàn)。首先,我們快速調(diào)查了小組內(nèi)現(xiàn)有的各種不同的平臺(tái)和瀏覽器組合,為適應(yīng)各種組合方式,編寫了如下代碼:
- /* 這里檢查游覽器是否支持DeviceOrientationEvent事件(鏈接到W3C)。*/
- if (window.DeviceOrientationEvent) {
- window.addEventListener('deviceorientation', function(e) {
- // 我們從事件“e”中獲取角度值并轉(zhuǎn)化成弧度值。
- leftRightAngle = e.gamma /90.0*Math.PI/2;
- frontBackAngle = e.beta /90.0*Math.PI/2;
- }, false);
- } else if (window.OrientationEvent) { //另一個(gè)選項(xiàng)是Mozilla版本同樣的東西
- window.addEventListener('MozOrientation', function(e) {
- //在這里將長度值當(dāng)做一個(gè)單位,并轉(zhuǎn)換成角度值,看起來運(yùn)行的不錯(cuò)。
- leftRightAngle = e.x * Math.PI/2;
- frontBackAngle = e.y * Math.PI/2;
- }, false);
- } else {
- // 自然地,沒有瀏覽器支持的大多數(shù)人會(huì)獲取這個(gè)。
- setStatus('Your device does not support orientation reading. Please use Android 4.0 or later, iOS (MBP laptop
- is fine) or similar platform.');
- }
結(jié)果是,代碼可以在版本較新的Chrome中正常運(yùn)行,也有人反饋說說它也可以運(yùn)行在較新版本的iOS上的Safari瀏覽器當(dāng)中(但是我手頭上的 Safari并不支持)。我決定不再試圖尋找那種能讀取所有可能用的瀏覽器中加速度計(jì)數(shù)據(jù)的普適性解決方案,因?yàn)楝F(xiàn)實(shí)是我們在Node淘汰賽的編碼環(huán)節(jié)中 個(gè)只有48小時(shí)的時(shí)間,而當(dāng)時(shí)游戲的架構(gòu)還沒有完成。
我決定使用Sylvester,它是一個(gè)碰撞檢測的向量和矩陣數(shù)學(xué)庫。其實(shí)我也可以使用Box2D JS來節(jié)省時(shí)間,但是由于有過Sylvester的使用經(jīng)驗(yàn),并且所需的碰撞檢測比較簡單,我還是決定使用Sylvester。檢查小球是否落到洞里去的代碼如下所示:
- function checkBallHole(ball, hole, dropped) {
- // 用Sylvester定義洞和求的位置為矢量對象
- var holeVector = $V([hole.x, hole.y]);
- var ballVector = $V([ball.x, ball.y]);
- // 在Sylvester中用向量簡單的計(jì)算距離
- if (ballVector.distanceFrom(holeVector) < hole.r) {
- // 用球的位置作為變量執(zhí)行回調(diào)函數(shù)
- dropped(ballVector);
- }
- }
所以事實(shí)上這里沒有什么復(fù)雜的:如果你的小球的中心位于洞內(nèi),那么就會(huì)觸發(fā)“dropped”的函數(shù)。這段代碼在每幀運(yùn)行一次,那么以前開發(fā)過游戲 的朋友都知道,這種實(shí)現(xiàn)方式可能會(huì)造成小球在這一幀內(nèi)飛躍洞穴而沒有掉進(jìn)去。然而,在日常生活中我們知道,如果你用足夠快的速度將小球推向洞穴,它是可以 滑過而不掉落的,所以這不是個(gè)問題。
這個(gè)游戲中也有墻體,所以碰撞檢測也是必須要做的。Sylvester提供了一種目標(biāo)與計(jì)算線狀對象的放發(fā),我用的就是這個(gè)。簡單的代碼如下:
- // 計(jì)算球和墻壁碰撞時(shí)的沖擊矢量數(shù)據(jù)
- 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]));
- // 計(jì)算墻與球的最近點(diǎn)(幾乎就要撞上的那個(gè)位置)
- var collisionPoint = wallSegment.pointClosestTo(ballVector)
- .to2D(); // needed by sylvester to convert 3D to 2D vector
- //sylvester將矢量數(shù)據(jù)從3D轉(zhuǎn)化成2D所需的變量,然后看這個(gè)距離在當(dāng)前框架內(nèi)為多少(并不是在兩個(gè)框架之間差距多少)
- var dist = collisionPoint.distanceFrom(ballVector);
- //天真的假設(shè)碰撞只發(fā)生在球和墻的距離小于球的半徑的情況下
- if (dist < ball.r) {
- //調(diào)整到一個(gè)合適的值。較大的逆質(zhì)量值意味著更大的影響(和較小的質(zhì)量)
- var inverseMassSum = 1/100.0;
- //從球心到碰撞點(diǎn)的向量
- var differenceVector = collisionPoint.subtract(ballVector);
- var collisionNormal = differenceVector.multiply(1.0/dist);
- // 球陷下去的部分相當(dāng)于在墻內(nèi)
- var penetrationDistance = ball.r-dist;
- //碰撞時(shí)球的速率
- var collisionVelocity = $V([ball.vx, ball.vy]);
- // 從點(diǎn)屬性中我們獲得沖擊速度
- var impactSpeed = collisionVelocity.dot(collisionNormal);
- if (impactSpeed >= 0) {
- // 計(jì)算沖擊量。運(yùn)動(dòng)能量在每次碰撞是以2-1-0.4=0.6的倍率遞減
- var impulse = collisionNormal.multiply(
- (-1.4)*impactSpeed/(inverseMassSum));
- //沖擊只會(huì)作用在球上,因?yàn)閴Ρ辉O(shè)計(jì)為固定的
- var newBallVelocity = $V([ball.vx, ball.vy]).add(
- impulse.multiply(inverseMassSum));
- //把值傳回原來的對象
- ball.vx = newBallVelocity.e(1);
- ball.vy = newBallVelocity.e(2);
- }
- }
- }
在實(shí)現(xiàn)小球和墻體的碰撞過程時(shí)我做了許多并非真實(shí)的假設(shè)(但是跟現(xiàn)實(shí)足夠接近)。首先,墻體的厚度為零(而不是實(shí)際上的5像素),而且,我沒有計(jì)算兩幀之 間發(fā)生了什么。很明顯,這會(huì)導(dǎo)致游戲中球體有能力穿越墻體。通過創(chuàng)建球體在不同幀之間的運(yùn)動(dòng)線段并找出球體三角與墻體之間是否有交叉,就很可以容易的測試 到是否會(huì)發(fā)生碰撞。那么我們就必須要計(jì)算小球和墻體發(fā)生碰撞的位置。在上文的代碼段中,這個(gè)位置數(shù)據(jù)就存在變量“collisionPoint”內(nèi)(見下圖)。
我很喜歡Ganvas和WebGL,但是我們計(jì)劃使用DOM和jQuery來做渲染,因?yàn)槲覀兂酥谱髑蝮w滾動(dòng)之外,不需要任何Ganvas和WebGL 的特效(如果這樣實(shí)現(xiàn),其實(shí)是很優(yōu)雅的,真可惜)。使用DOM渲染的場景在縮放時(shí)有點(diǎn)生硬,但它很容易實(shí)現(xiàn)。我寫了下面的函數(shù)用于繪制游戲中的子畫面。
- //設(shè)置DOM元素屬性以反映sprite對象
- setElementPosition: function(element, sprite) {
- // 同步sprite維數(shù)
- sprite.width = (maze.getSquareWidth() * sprite.r * 2);
- sprite.height = (maze.getSquareHeigth() * sprite.r * 2);
- var x = sprite.x;
- var y = sprite.y;
- /* 在絕對定位中計(jì)算樣式屬性left和top的值
- * 從而確保點(diǎn)(x,y)在sprire的中心位置(使距離計(jì)算更加簡單)
- */
- var newLeft = (x * maze.getSquareWidth() - element.width() / 2.0);
- var newTop = (y * maze.getSquareHeigth() - element.height() / 2.0);
- // 避免sprite因?yàn)槭艿絺鞲衅鞒掷m(xù)輸入的影響而產(chǎn)生的顫抖
- // 通過一個(gè)閾值判斷是否顯示球在屏幕上的移動(dòng)。
- // 這是一個(gè)相當(dāng)大的閾值,對于某些設(shè)備來說應(yīng)該選擇較小的值。
- if (thresholded(element.css('left') - newLeft, 5) !== 0) {
- //設(shè)置DOM元素的x坐標(biāo)位置
- element.css('left', parseInt(newLeft) + 'px');
- }
- if (thresholded(element.css('top') - newTop, 5) !== 0) {
- //設(shè)置DOM元素的y坐標(biāo)位置
- element.css('top', parseInt(newTop) + 'px');
- }
- //設(shè)置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位置的調(diào)試信息。通過點(diǎn)擊‘enter’顯示調(diào)試信息。
- element.find('.location').html('('+parseInt(sprite.x*10)/10.0+','+parseInt(sprite.y*10)/10.0+')');
- },
我做了一個(gè)根據(jù)視角實(shí)時(shí)縮放的功能,因此在每個(gè)框架中的寬度和高度都是計(jì)算得到的。很不幸在游戲中沒有體現(xiàn)出這點(diǎn),因?yàn)槲覀儑L試編程控制瀏覽器旋轉(zhuǎn)失敗了(沒有用于此項(xiàng)功能的接口,所以這還需要破解)。所以我們最后決定,通知用戶關(guān)閉手機(jī)瀏覽器的旋轉(zhuǎn)功能,如下圖所示:
所有的加速度計(jì)數(shù)據(jù)的讀取,物理引擎的運(yùn)行和DOM渲染都被歸攏到一個(gè)主循環(huán)中了。我將所有的主循環(huán)的代碼放置到函數(shù)“update”中并且每100毫秒運(yùn)行一次(我知道這不夠頻繁,但是它在我的設(shè)備上運(yùn)行的很好,所以就暫時(shí)忽略這個(gè)設(shè)定值吧),像這樣:
- window.setInterval(function() { update(); }, 100);
客戶端的所有源代碼可以點(diǎn)擊這里獲取。
順便提一句,我對于新式的視網(wǎng)膜MacBook Pros非常失望,它沒有加速計(jì)(就像我們某位玩家提到的),因?yàn)樗鼈兊腟SD驅(qū)動(dòng)器沒有可以移動(dòng)的部件!所以也許以筆記本旋轉(zhuǎn)為基礎(chǔ)的游戲看起來要到此為止了。