使用 PhysicsJS 構建一個 2D 的瀏覽器游戲
作為一名Web開發人員同事又是物理極客,每當我試圖使用JavaScript做2D物理的時候總覺得少了點什么。我想要一個可擴展的框架,緊隨其他現代JavaScript API的腳步。我不希望它干涉性的假設我想要重力朝下,或者說我想要沒有引力的物理環境... 這驅使我創造出了PhysicsJS,我希望它不會辜負其口號::
一個模塊化,可擴展的,易于使用的JavaScript物理引擎.
</blockquote>還有一些新的JavaScript物理引擎正暫露頭角,他們都有各自的優點。而我想告訴你的是我所覺得的PhysicsJS中真正很酷的東西,我也相信這會幫助其在其它引擎中脫穎而出。
在這篇文章中,我將指導你完成一個完整的2D小行星風格的游戲,里面充滿著火爆絢麗的圖形。在這一過程中,你會看到我的一些開發風格,它會向你展示一些洞察PhysicsJS的更高級用法。你可以在GitHub上的PhysicsJS庫(可以看到一些實例/飛船)中下載完整的演示代碼,還可以看到CodePen的最終產品。而在我們開始之前,讓我先闡述一個重要的聲明:
PhysicsJS是一個新的庫,并且我們正在努力讓它在未來幾個月內進入公測。該API正在不斷變化中,而且在API中仍然有錯誤和漏洞。所以請記住這一點。 (我們正在開發PhysicsJS v0.5.2)。我正在尋找的任何水平有保證的貢獻者。請即刻就到PhysicsJS GitHub并且指出其中謬誤吧!我也會對StackOverflow上有關PhysicsJS的問題作答復,只要你在問題上標記了“physicsjs”。
</blockquote>Stage 0: 空間的虛無
第一件要做的事情。我們需要設置好我們的HTML。我們將使用HTML canvas進行渲染,所以就要從標準HTML5的樣板代碼開始。我將假定腳本放在body標簽的尾部,使我們不用擔心DOM的加載。我們也將使用RequireJS來加載所有的腳本。如果你還沒有使用過RequireJS,并且不喜歡學習,那么你就必須拜托任何define()和任何require()的方法,并且將所有的JavaScript以正確的順序組織在一起(而我則強烈推薦你使用RequireJS)。
下面是我工作時的目錄結構:index.html images/ ... js/ require.js physicsjs/ ...讓我們繼續,下載RequireJS,PhysicsJS-0.5.2以及相關的圖像,并把它們放到上面這樣的目錄結構中。
接下來,在頭部添加CSS,根據游戲顯示圖片和信息的類名規定他們的樣式:
html, body { padding: 0; margin: 0; width: 100%; height: 100%; overflow: hidden; } body:after { font-size: 30px; line-height: 140px; text-align: center; color: rgb(60, 16, 11); background: rgba(255, 255, 255, 0.6); position: absolute; top: 50%; left: 50%; width: 400px; height: 140px; margin-left: -200px; margin-top: -70px; border-radius: 5px; } body.before-game:after { content: 'press "z" to start'; } body.lose-game:after { content: 'press "z" to try again'; } body.win-game:after { content: 'Win! press "z" to play again'; } body { background: url(images/starfield.png) 0 0 repeat; }在body標簽結束的地方添加下面代碼用來載入RequireJS和初始化我們的應用:
<script src="js/require.js"></script> <script src="js/main.js"></script>下一步我們需要創建main.js文件,這個文件將在本教程中不斷被更改。請注意,我上傳的代碼是在CodePen上可以執行的示例,所以你可能需要更改你本地示例中的RequireJS的文件路徑。下面的代碼可能對你有些幫助:
// 開始 main.js require( { // use top level so we can access images baseUrl: './', packages: [{ name: 'physicsjs', location: 'js/physicsjs', main: 'physicsjs' }] }, ...OK,讓我們開始創建第一個版本的main.js吧。
這將是一個——史無前例的——絕無僅有的——空前絕后的——無聊的游戲(靠!)。
那么應該從哪開始呢?首先,我們以RequireJS的依賴庫的形式加載PhisicsJS庫。我們也加載一些其他的依賴庫。這些是PhisicJS的擴展,可以為它添加一些功能,比如說循環體,碰撞檢測等。當使用RequireJS(和AMD模塊)時,我們需要像這樣聲明每一個必需的依賴。它允許我們只加載我們實際上使用的依賴庫。
在我們的RequireJS工廠函數中,我們可以建立起我們的游戲。首先添加一個class名到body標簽中,這樣就可以顯示“按下'Z'鍵開始”消息,然后我們監聽“Z”鍵的鍵盤事件,觸發時調用我們的helper中的newGame()方法。
接下來我們設置canvas渲染填充窗口并且監聽窗體的resize事件以調整canvas的大小。
接下來,使用init()函數初始化我們的基本元素。init()函數將被傳給Physics()構造器,用來創建一個世界。在這個函數中,我們需要進行一些設置:
- 一艘“船”(現在也就是個〇而已)
- 一個星球 (另一個〇,不過要給它加上圖片)
- 一個監聽器,用來監聽世界中發生的操作
</ul>對于星球,我可能需要作出一些額外的解釋。事實上,它是這樣的:
planet.view = new Image(); planet.view.src = require.toUrl('images/planet.png');這是什么鬼東西0.0?
上面的操作是用來創建一個圖片并將其儲存在view屬性中。該圖片將會載入planet.png(使用RequireJS解析圖片路徑)。但是我們為什么要這么做呢?這是為了定制我們的對象的顯示方式。cancas渲染器會監視所有它需要渲染的元素,如果沒有設置這個屬性,那么canvas將會畫出指定的元素(圓形、多邊形)并作為圖片存在緩存中,圖片就存在body的view屬性中。如果屬性中已存在圖片,那么渲染器將會使用這個現成的圖片。
注意: 這遠非定制渲染過程的最佳方案,但是這里有兩個好消息要告訴你:
- 渲染器極易擴展或被其他你想要的東西替代,這完全取決于你是否要創建一個完全不同的渲染器。此處有關于這方面的詳細文檔。
- 在未來的幾周, ChallengePost將會為PhysicsJS搭建功能更加豐富和強大的渲染器。你可以考慮使用這個渲染器。即使你不用,至少你也可以得到些好處。
</ol>扯遠了,讓我們繼續。現在我們繼續看init()函數,這是完成這個函數的最后一步:
// add things to the world world.add([ ship, planet, Physics.behavior('newtonian', { strength: 1e-4 }), Physics.behavior('sweep-prune'), Physics.behavior('body-collision-detection'), Physics.behavior('body-impulse-response'), renderer ]);我們一次性獎所有的組件都加入世界:創建的船、星球、一些動作、渲染器。下面我們逐一分析這些動作:
- newtonian: 為世界加入牛頓引力(newtonian gravity)。物體會被1e-4的平方大小的相互作用力吸引.
- sweep-prune: 這是一個廣義相位算法,加速碰撞檢測.如果你的游戲涉及到物體碰撞,那么最好用上這個東西。
- body-collision-detection: 這個狹義碰撞檢測算法用來監聽sweep-prune事件并用像GJK一類的算法計算出碰撞效果。這只會檢測碰撞,而不會對碰撞做出任何反應。
- body-impulse-response: 這才是對碰撞做出響應的相關動作。它監聽碰撞事件并通過對碰撞的物體應用一定的沖量對碰撞做出響應。
</ul>希望就在前方!你現在已經開始接觸PhysicsJS模塊化的本質了。無論是物體亦或是世界本身都不包含任何物理邏輯,如果你想要在你的世界中運用物理規律而不是在真空環境中,你需要創建一些行為并附加在世界中、表現在物體上。
接下來我們在main.js腳本中加入一個新的Game()輔助函數,通過調用Physics(init)來達到清理和創建新游戲的目的。它同時還監聽新世界中的用戶事件來終止和重啟游戲。現在這些事件還不會做出任何響應,不過快了。
最后,我們與心跳建立連接(requestAnimationFramehelper)讓每個框架都能調用world.step函數。
步驟1: 組織元素
越來越有趣了不是么?特別是當我們可以開著飛船移動的時候。和我們對星球所做的操作一樣,我們也可以對飛船應用相應的操作,但是在此我們需要多個自定義皮膚。我們需要一些方法來操縱飛船,這里最佳的選擇也是我最喜歡PhysicsJS的地方:幾乎無限制的擴展。在這個例子中,我們將會對body進行擴展。
創建一個新的文件,命名為player.js。這個文件用來定義一個名為player的自定義物體。我們假設一個特別的飛船 (奶牛飛船), 然后向WIKI中說的那樣擴展PhysicsJS。
為模塊定義RequireJS:
define( [ 'require', 'physicsjs', 'physicsjs/bodies/circle', 'physicsjs/bodies/convex-polygon' ], function( require, Physics ){ // code here... });現在我們獲得了一個圓形物體和一些被摧毀的多邊形碎片(爆炸效果嘛,之前承諾過的不是)。準備就緒,動起來~
// extend the circle body Physics.body('player', 'circle', function( parent ){ // private helpers // ... return { // extension definition }; });body將會被附加如PhysicsJS,在其中加入些死人輔助函數并傳回一個普通對象用以擴展圓形的主體。
這是我們的私有helper:
// private helpers var deg = Math.PI/180; var shipImg = new Image(); var shipThrustImg = new Image(); shipImg.src = require.toUrl('images/ship.png'); shipThrustImg.src = require.toUrl('images/ship-thrust.png');var Pi2 = 2 Math.PI; // VERY crude approximation to a gaussian random number.. but fast var gauss = function gauss( mean, stddev ){ var r = 2 (Math.random() + Math.random() + Math.random()) - 3; return r * stddev + mean; }; // will give a random polygon that, for small jitter, will likely be convex var rndPolygon = function rndPolygon( size, n, jitter ){
var points = [{ x: 0, y: 0 }] ,ang = 0 ,invN = 1 / n ,mean = Pi2 * invN ,stddev = jitter * (invN - 1/(n+1)) * Pi2 ,i = 1 ,last = points[ 0 ] ; while ( i < n ){ ang += gauss( mean, stddev ); points.push({ x: size * Math.cos( ang ) + last.x, y: size * Math.sin( ang ) + last.y }); last = points[ i++ ]; } return points;
};</pre>
這是我們對圓形物體的擴展(具體細節請看注釋):
return { // we want to do some setup when the body is created // so we need to call the parent's init method // on "this" init: function( options ){ parent.init.call( this, options ); // set the rendering image // because of the image i've chosen, the nose of the ship // will point in the same angle as the body's rotational position this.view = shipImg; }, // this will turn the ship by changing the // body's angular velocity to + or - some amount turn: function( amount ){ // set the ship's rotational velocity this.state.angular.vel = 0.2 amount deg; return this; }, // this will accelerate the ship along the direction // of the ship's nose thrust: function( amount ){ var self = this; var world = this._world; if (!world){ return self; } var angle = this.state.angular.pos; var scratch = Physics.scratchpad(); // scale the amount to something not so crazy amount = 0.00001; // point the acceleration in the direction of the ship's nose var v = scratch.vector().set( amount Math.cos( angle ), amount * Math.sin( angle ) ); // accelerate self this.accelerate( v ); scratch.done();// if we're accelerating set the image to the one with the thrusters on if ( amount ){ this.view = shipThrustImg; } else { this.view = shipImg; } return self; }, // this will create a projectile (little circle) // that travels away from the ship's front. // It will get removed after a timeout shoot: function(){ var self = this; var world = this._world; if (!world){ return self; } var angle = this.state.angular.pos; var cos = Math.cos( angle ); var sin = Math.sin( angle ); var r = this.geometry.radius + 5; // create a little circle at the nose of the ship // that is traveling at a velocity of 0.5 in the nose direction // relative to the ship's current velocity var laser = Physics.body('circle', { x: this.state.pos.get(0) + r * cos, y: this.state.pos.get(1) + r * sin, vx: (0.5 + this.state.vel.get(0)) * cos, vy: (0.5 + this.state.vel.get(1)) * sin, radius: 2 }); // set a custom property for collision purposes laser.gameType = 'laser'; // remove the laser pulse in 600ms setTimeout(function(){ world.removeBody( laser ); laser = undefined; }, 600); world.add( laser ); return self; }, // 'splode! This will remove the ship // and replace it with a bunch of random // triangles for an explosive effect! blowUp: function(){ var self = this; var world = this._world; if (!world){ return self; } var scratch = Physics.scratchpad(); var rnd = scratch.vector(); var pos = this.state.pos; var n = 40; // create 40 pieces of debris var r = 2 * this.geometry.radius; // circumference var size = 8 * r / n; // rough size of debris edges var mass = this.mass / n; // mass of debris var verts; var d; var debris = []; // create debris while ( n-- ){ verts = rndPolygon( size, 3, 1.5 ); // get a random polygon if ( Physics.geometry.isPolygonConvex( verts ) ){ // set a random position for the debris (relative to player) rnd.set( Math.random() - 0.5, Math.random() - 0.5 ).mult( r ); d = Physics.body('convex-polygon', { x: pos.get(0) + rnd.get(0), y: pos.get(1) + rnd.get(1), // velocity of debris is same as player vx: this.state.vel.get(0), vy: this.state.vel.get(1), // set a random angular velocity for dramatic effect angularVelocity: (Math.random()-0.5) * 0.06, mass: mass, vertices: verts, // not tooo bouncy restitution: 0.8 }); d.gameType = 'debris'; debris.push( d ); } } // add debris world.add( debris ); // remove player world.removeBody( this ); scratch.done(); return self; }
};</pre>你可能注意到我們正在使用一些叫做Phisics.scratchpad的東西。這是你的好朋友。一個scratchpad是一個通過回收臨時對象(數組)來減少創建對象和回收 垃圾時間的helper。點擊 這里,你可以讀到更多關于scratchpads的信息。
那么現在我們有了一個玩家,但是它還沒有和任何用戶輸入相關聯起來。我們要做的就是創建一個玩家動作以響應用戶的輸入。所以我們用相似的方式創建另一個文件,叫做player-behavior.js(具體細節請看注釋):
define( [ 'physicsjs' ], function( Physics ){return Physics.behavior('player-behavior', function( parent ){ return { init: function( options ){ var self = this; parent.init.call(this, options); // the player will be passed in via the config options // so we need to store the player var player = self.player = options.player; self.gameover = false; // events document.addEventListener('keydown', function( e ){ if (self.gameover){ return; } switch ( e.keyCode ){ case 38: // up self.movePlayer(); break; case 40: // down break; case 37: // left player.turn( -1 ); break; case 39: // right player.turn( 1 ); break; case 90: // z player.shoot(); break; } return false; }); document.addEventListener('keyup', function( e ){ if (self.gameover){ return; } switch ( e.keyCode ){ case 38: // up self.movePlayer( false ); break; case 40: // down break; case 37: // left player.turn( 0 ); break; case 39: // right player.turn( 0 ); break; case 32: // space break; } return false; }); }, // this is automatically called by the world // when this behavior is added to the world connect: function( world ){ // we want to subscribe to world events world.subscribe('collisions:detected', this.checkPlayerCollision, this); world.subscribe('integrate:positions', this.behave, this); }, // this is automatically called by the world // when this behavior is removed from the world disconnect: function( world ){ // we want to unsubscribe from world events world.unsubscribe('collisions:detected', this.checkPlayerCollision); world.unsubscribe('integrate:positions', this.behave); }, // check to see if the player has collided checkPlayerCollision: function( data ){ var self = this ,world = self._world ,collisions = data.collisions ,col ,player = this.player ; for ( var i = 0, l = collisions.length; i < l; ++i ){ col = collisions[ i ]; // if we aren't looking at debris // and one of these bodies is the player... if ( col.bodyA.gameType !== 'debris' && col.bodyB.gameType !== 'debris' && (col.bodyA === player || col.bodyB === player) ){ player.blowUp(); world.removeBehavior( this ); this.gameover = true; // when we crash, we'll publish an event to the world // that we can listen for to prompt to restart the game world.publish('lose-game'); return; } } }, // toggle player motion movePlayer: function( active ){ if ( active === false ){ this.playerMove = false; return; } this.playerMove = true; }, behave: function( data ){ // activate thrusters if playerMove is true this.player.thrust( this.playerMove ? 1 : 0 ); } }; });
});</pre>接下來我們可以聲明js/playerandjs/player-behavior作為依賴庫,并將它添加進我們的main.js文件的init()函數里,這樣我們就可以使用了。
/ in init() var ship = Physics.body('player', { x: 400, y: 100, vx: 0.08, radius: 30 });var playerBehavior = Physics.behavior('player-behavior', { player: ship });
// ...
world.add([ ship, playerBehavior, //... ]);</pre>在我們看到我們的第二個場景之前,我們最后需要添加的東西是獲取渲染的畫布來跟蹤用戶的動作。這可以通過添加一些代碼到stepevent listener來做到,在它調用world.render()方法前改變渲染的位置,就像下面這樣:
// inside init()... // render on every step world.subscribe('step', function(){ // middle of canvas var middle = { x: 0.5 * window.innerWidth, y: 0.5 * window.innerHeight }; // follow player renderer.options.offset.clone( middle ).vsub( ship.state.pos ); world.render(); });我們第二個迭代現在看起來更像一個游戲了。步驟2:“自找麻煩”
現在,它已經是一個可愛的小游戲了。但是我們需要一些“敵人”。接下來,讓我們來創建一些吧!
我們將要做幾乎相同的事情,就像剛剛我們對玩家角色所做的一樣。我們將要創建一個新的身體(擴展的圓環),但是我們接下來要做的事非常簡單。因為我們只要敵人爆炸,所以我們采用不同的方式來創建它們,并且不創建動作,因為這些功能是不需要的。我們創建一個叫做dufo.js的新文件,并且給我們的UFO一個簡單方法blowUp()。
define( [ 'require', 'physicsjs', 'physicsjs/bodies/circle' ], function( require, Physics ){Physics.body('ufo', 'circle', function( parent ){ var ast1 = new Image(); ast1.src = require.toUrl('images/ufo.png'); return { init: function( options ){ parent.init.call(this, options); this.view = ast1; }, blowUp: function(){ var self = this; var world = self._world; if (!world){ return self; } var scratch = Physics.scratchpad(); var rnd = scratch.vector(); var pos = this.state.pos; var n = 40; var r = 2 * this.geometry.radius; var size = r / n; var mass = 0.001; var d; var debris = []; // create debris while ( n-- ){ rnd.set( Math.random() - 0.5, Math.random() - 0.5 ).mult( r ); d = Physics.body('circle', { x: pos.get(0) + rnd.get(0), y: pos.get(1) + rnd.get(1), vx: this.state.vel.get(0) + (Math.random() - 0.5), vy: this.state.vel.get(1) + (Math.random() - 0.5), angularVelocity: (Math.random()-0.5) * 0.06, mass: mass, radius: size, restitution: 0.8 }); d.gameType = 'debris'; debris.push( d ); } setTimeout(function(){ for ( var i = 0, l = debris.length; i < l; ++i ){ world.removeBody( debris[ i ] ); } debris = undefined; }, 1000); world.add( debris ); world.removeBody( self ); scratch.done(); world.publish({ topic: 'blow-up', body: self }); return self; } }; });
});</pre>接下來,我們在main.js的innit()方法中 創建一些敵人。
// inside init()... var ufos = []; for ( var i = 0, l = 30; i < l; ++i ){var ang = 4 * (Math.random() - 0.5) * Math.PI; var r = 700 + 100 * Math.random() + i * 10; ufos.push( Physics.body('ufo', { x: 400 + Math.cos( ang ) * r, y: 300 + Math.sin( ang ) * r, vx: 0.03 * Math.sin( ang ), vy: - 0.03 * Math.cos( ang ), angularVelocity: (Math.random() - 0.5) * 0.001, radius: 50, mass: 30, restitution: 0.6 }));
}
//...
world.add( ufos );</pre>這里的數學運算只是為了讓它們用這樣一種方式隨機地繞行地球,而且慢慢地朝地球蠕動過去。但現在,我們還不能射擊他們,因此讓我們向init()函數添加更多的代碼來跟蹤我們消滅了多少敵人,而如果我們消滅了它們,就會釋放出AWIN-game事件。我們還將偵聽這個世界中的碰撞:detected事件,并且任何沖突都會有激光在其中,如果它支持的話,此時我方會對其大發脾氣。
// inside init()... // count number of ufos destroyed var killCount = 0; world.subscribe('blow-up', function( data ){killCount++; if ( killCount === ufos.length ){ world.publish('win-game'); }
});
// blow up anything that touches a laser pulse world.subscribe('collisions:detected', function( data ){ var collisions = data.collisions ,col ;
for ( var i = 0, l = collisions.length; i < l; ++i ){ col = collisions[ i ]; if ( col.bodyA.gameType === 'laser' || col.bodyB.gameType === 'laser' ){ if ( col.bodyA.blowUp ){ col.bodyA.blowUp(); } else if ( col.bodyB.blowUp ){ col.bodyB.blowUp(); } return; } }
});</pre>
請注意,這里我們可以創建一個新的行為來管理這些UFO,而代碼非常少,所以這里提沒有必要。我也想要向大家展示PhysicsJS是如何的多才多藝,因為它能容納不同的編碼風格,達成目標可以用不同的方式。
好吧,讓我們來看看我們的main.js的第三次迭代吧:
太棒了!我們幾乎已經完成了。只是有一件事我還想提醒你一下...
第3步:尋找我們的方式
空間會讓人少許有一點點迷惑。你很難把你的周圍觀察清楚,因此有時你需要一些幫助。為了解決這個問題,就讓我們創建一個雷達小地圖吧!那么,我們如何才能做到這一點呢?
我們得把所有天體的位置并且在頂部右側角落的一塊小畫布上畫上小點。雖然渲染器的功能并不是完備的,但是我想在這一點上,我們可以使用我們自己掌握的一些有用的輔助方法。我們要做的是將所謂的渲染器完成對物體的渲染這一個事件綁定到render事件。然后,我們使用這些輔助方法就可以將我們的小地圖繪制到幀上面。下面是代碼:
// inside init()... // draw minimap world.subscribe('render', function( data ){ // radius of minimap var r = 100; // padding var shim = 15; // x,y of center var x = renderer.options.width - r - shim; var y = r + shim; // the ever-useful scratchpad to speed up vector math var scratch = Physics.scratchpad(); var d = scratch.vector(); var lightness;// draw the radar guides renderer.drawCircle(x, y, r, { strokeStyle: '#090', fillStyle: '#010' }); renderer.drawCircle(x, y, r * 2 / 3, { strokeStyle: '#090' }); renderer.drawCircle(x, y, r / 3, { strokeStyle: '#090' }); for (var i = 0, l = data.bodies.length, b = data.bodies[ i ]; b = data.bodies[ i ]; i++){ // get the displacement of the body from the ship and scale it d.clone( ship.state.pos ).vsub( b.state.pos ).mult( -0.05 ); // color the dot based on how massive the body is lightness = Math.max(Math.min(Math.sqrt(b.mass*10)|0, 100), 10); // if it's inside the minimap radius if (d.norm() < r){ // draw the dot renderer.drawCircle(x + d.get(0), y + d.get(1), 1, 'hsl(60, 100%, '+lightness+'%)'); } } scratch.done();
});</pre>
OK,讓我們把這個添加到ourmain.js上,然后看看成品!
結束語
瞧!就是這樣!謝謝你忍受了我這篇常常的教程。
顯然,這個游戲的體驗不是最好的,而且有更多的地方亟需完善(比如使用RequireJS構建工具壓縮我們的腳本等等),但我的目標是讓你對PhysicsJS的功能有一個了解,以及即使是在它的早期階段它也是那么實用。希望這些東西能夠給你足夠的思考空間。如果有任何疑問,請你不要忘了,可以在評論列表或者StackOverflow上隨意自由發表評論。