使用HTML5開發體感游戲:VeloMaze的開發案例
我打算公開該游戲的技術背景,及其如何在多種網絡技術基礎之上構建整個項目。應用在該游戲中的技術有: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驅動器沒有可以移動的部件!所以也許以筆記本旋轉為基礎的游戲看起來要到此為止了。