使用JavaScript和Canvas寫一個游戲框架
4、寫一個游戲框架(一)
http://www.brighthub.com/internet/web-development/articles/40512.aspx
在知道了如何使用畫布元素之后,接下來我教大家寫一個框架,有了這個框架,我們就可以把它作為基礎(chǔ)來創(chuàng)建游戲。在這第一部分,我們會介紹前兩個文件/類。
編寫代碼之前,我們先來看一看隨后幾篇文章將致力于創(chuàng)建的示例Demo。表面上看起來,這個Demo跟第二篇文章里的那個沒啥區(qū)別,但如果你看看后臺(查看網(wǎng)頁源代碼)就會發(fā)現(xiàn),為了更方便地創(chuàng)建這個最終效果,一個凝聚不少心血的基礎(chǔ)框架已經(jīng)寫好了。

下面我們要介紹的JavaScript代碼使用面向?qū)ο蟮姆绞絹砭帉?。對于沒有編寫過多少JavaScript代碼的人來說,恐怕第一眼看到它們會覺得有點奇怪。如果你真的不太熟悉JavaScript的面向?qū)ο缶幊蹋ㄗh通過Mozilla Developer Network的這個教程https://developer.mozilla.org/en/Introduction_to_Object-Oriented_JavaScript來補補課。這篇教程里解釋了我們稍后會用到的一些編程技術(shù)。
從設(shè)計思想上來看,這個框架可以分成兩部分:與底層的2D引擎交互的類(用于操作畫布、控制渲染循環(huán)、處理輸入等的代碼)和用來創(chuàng)建對象以便構(gòu)成游戲的類。前者可以歸為引擎類,后者可以歸為應用類。由于應用類要構(gòu)建于引擎類之上,所以我們需要先來創(chuàng)建引擎類。
Main.js
如果你研究了前面例子中的代碼,就會發(fā)現(xiàn)Main.js文件中包含了不少代碼。
- /** 每秒多少幀
- @type Number
- */
- var FPS = 30;
- /** 兩幀間間隔的秒數(shù)
- @type Number
- */
- var SECONDS_BETWEEN_FRAMES = 1 / FPS;
- /** GameObjectManager 實例的全局引用
- @type GameObjectManager
- */
- var g_GameObjectManager = null;
- /** 應用中用到的圖像
- @type Image
- */
- var g_image = new Image();
- g_image.src = "jsplatformer3-smiley.jpg";
- // 將應用的入口設(shè)置為init函數(shù)
- window.onload = init;
- /**
- 應用的入口
- */
- function init()
- {
- new GameObjectManager().startupGameObjectManager();
- }
首先是定義全局變量的代碼。然后,跟以前一樣,當頁面加載完畢后立即運行init函數(shù)。在init函數(shù)里,創(chuàng)建GameObjectManager類的實例。
這里在GameObjectManager類的實例上調(diào)用了startupGameObjectManager函數(shù)。這篇文章以及后面的幾篇文章還將多次提到幾個命名上具有startupClassName形式的函數(shù)。這些函數(shù)實際上充當了各自類的構(gòu)造函數(shù),這樣做有兩個原因。
首先,JavaScript不支持函數(shù)重載(至少不容易實現(xiàn))。如果你想讓一個類有多個構(gòu)造函數(shù),那么這就成了問題。而通過把構(gòu)造工作分配給另一組函數(shù)(如startupClassName1、startupClassName2),就可以比較容易地定義構(gòu)造類的不同方式了。
第二個原因(很大程度上也是個人的問題)是我經(jīng)常會在構(gòu)造函數(shù)中引用尚未定義的變量。這可能是我使用C++、Java和C#這些語言落下的毛病,在這些語言里,類變量在源代碼中的位置對其在構(gòu)造函數(shù)中的可見性沒有影響。拿下面這個C#類為例:
- class Test
- {
- public void Test() {this.a = 5;}
- public int a;
- }
這些代碼是合乎語法的,可以正常工作。下面再看看JavaScript中一個相同的例子:
- function Test()
- {
- this.a = 5;
- var a;
- }
這段代碼的問題在于,局部變量a在我們把數(shù)值5賦給它的時候還不存在。只有運行到var a;這一行,變量a才存在。盡管這個例子有點故意編排的意味,但的確能夠說明我所遇到的問題。通過把類的創(chuàng)建放到一個類似startupClassName這樣的函數(shù)中完成,并且在構(gòu)造函數(shù)中定義(但不初始化)局部變量,然后當我在這些構(gòu)建函數(shù)中引用相應的局部變量時,就能夠確保它們一定是存在的。
#p#
GameObjectManager.js
- /**
- 管理游戲中所有對象的管理器
- @class
- */
- function GameObjectManager()
- {
- /** 保存游戲中對象的數(shù)組
- @type Arary
- */
- this.gameObjects = new Array();
- /** 上一次幀被渲染的時間
- @type Date
- */
- this.lastFrame = new Date().getTime();
- /** x軸的全局滾動值
- @type Number
- */
- this.xScroll = 0;
- /** y軸的全局滾動值
- @type Number
- */
- this.yScroll = 0;
- /** 對ApplicationManager實例的引用
- @type ApplicationManager
- */
- this.applicationManager = null;
- /** 對畫布元素的引用
- @type HTMLCanvasElement
- */
- this.canvas = null;
- /** 對畫布元素2D上下文的引用
- @type CanvasRenderingContext2D
- */
- this.context2D = null;
- /** 對內(nèi)存中用作后臺緩沖區(qū)的畫布的引用
- @type HTMLCanvasElement
- */
- this.backBuffer = null;
- /** 對后臺緩沖畫布的2D上下文的引用
- @type CanvasRenderingContext2D
- */
- this.backBufferContext2D = null;
- /**
- 初始化這個對象
- @return A reference to the initialised object
- */
- this.startupGameObjectManager = function()
- {
- // 設(shè)置引用this對象的全局指針
- g_GameObjectManager = this;
- // 取得畫布元素及其2D上下文的引用
- this.canvas = document.getElementById('canvas');
- thisthis.context2D = this.canvas.getContext('2d');
- this.backBuffer = document.createElement('canvas');
- thisthis.backBuffer.width = this.canvas.width;
- thisthis.backBuffer.height = this.canvas.height;
- thisthis.backBufferContext2D = this.backBuffer.getContext('2d');
- // 創(chuàng)建一個新的ApplicationManager
- this.applicationManager = new ApplicationManager().startupApplicationManager();
- // 使用setInterval來調(diào)用draw函數(shù)
- setInterval(function(){g_GameObjectManager.draw();}, SECONDS_BETWEEN_FRAMES);
- return this;
- }
- /**
- 渲染循環(huán)
- */
- this.draw = function ()
- {
- // 計算從上一幀到現(xiàn)在的時間
- var thisFrame = new Date().getTime();
- var dt = (thisFrame - this.lastFrame)/1000;
- this.lastFrame = thisFrame;
- // 清理繪制上下文
- this.backBufferContext2D.clearRect(0, 0, this.backBuffer.width, this.backBuffer.height);
- this.context2D.clearRect(0, 0, this.canvas.width, this.canvas.height);
- // 首先更新所有游戲?qū)ο?
- for (x in this.gameObjects)
- {
- if (this.gameObjects[x].update)
- {
- this.gameObjects[x].update(dt, this.backBufferContext2D, this.xScroll, this.yScroll);
- }
- }
- // 然后繪制所有游戲?qū)ο?
- for (x in this.gameObjects)
- {
- if (this.gameObjects[x].draw)
- {
- this.gameObjects[x].draw(dt, this.backBufferContext2D, this.xScroll, this.yScroll);
- }
- }
- // 將后臺緩沖復制到當前顯示的畫布
- this.context2D.drawImage(this.backBuffer, 0, 0);
- };
- /**
- 向gameObjects集合中添加一個GameObject
- @param gameObject The object to add
- */
- this.addGameObject = function(gameObject)
- {
- this.gameObjects.push(gameObject);
- this.gameObjects.sort(function(a,b){return a.zOrder - b.zOrder;})
- };
- /**
- 從gameObjects集合中刪除一個GameObject
- @param gameObject The object to remove
- */
- this.removeGameObject = function(gameObject)
- {
- this.gameObjects.removeObject(gameObject);
- }
- }
首先看一看GameObjectManager類。GameObjectManager是一個引擎類,用于管理畫布的繪制操作,還負責分派GameObject類(下一篇文章里介紹)的事件。
GameObjectManager類的startupGameObjectManager函數(shù)的代碼如下:
- /**
- 初始化這個對象
- @return A reference to the initialised object
- */
- this.startupGameObjectManager = function()
- {
- // 設(shè)置引用this對象的全局指針
- g_GameObjectManager = this;
- // 取得畫布元素及其2D上下文的引用
- this.canvas = document.getElementById('canvas');
- thisthis.context2D = this.canvas.getContext('2d');
- this.backBuffer = document.createElement('canvas');
- thisthis.backBuffer.width = this.canvas.width;
- thisthis.backBuffer.height = this.canvas.height;
- thisthis.backBufferContext2D = this.backBuffer.getContext('2d');
- // 創(chuàng)建一個新的ApplicationManager
- this.applicationManager = new ApplicationManager().startupApplicationManager();
- // 使用setInterval來調(diào)用draw函數(shù)
- setInterval(function(){g_GameObjectManager.draw();}, SECONDS_BETWEEN_FRAMES);
- return this;
- }
前面已經(jīng)說過,我們會把每個類的初始化工作放在startupClassName函數(shù)中來做。因此,GameObjectManager類將由startupGameObjectManager函數(shù)進行初始化。
而引用這個GameObjectManager實例的全局變量g_GameObjectManager經(jīng)過重新賦值,指向了這個新實例。
- // 設(shè)置引用this對象的全局指針
- g_GameObjectManager = this;
對畫布元素及其繪圖上下文的引用也同樣保存起來:
- // 取得畫布元素及其2D上下文的引用
- this.canvas = document.getElementById('canvas');
- thisthis.context2D = this.canvas.getContext('2d');
在前面的例子中,所有繪圖操作都是直接在畫布元素上完成的。這種風格的渲染一般稱為單緩沖渲染。在此,我們要使用一種叫做雙緩沖渲染的技術(shù):任意游戲?qū)ο蟮乃欣L制操作,都將在一個內(nèi)存中的附加畫布元素(后臺緩沖)上完成,完成后再通過一次操作把它復制到網(wǎng)頁上的畫布元素(前臺緩沖)。
雙緩沖技術(shù)(http://www.brighthub.com/internet/web-development/articles/11012.aspx)通常用于減少畫面抖動。我自己在測試的時候從沒發(fā)現(xiàn)直接向畫布元素上繪制有抖動現(xiàn)象,但我在網(wǎng)上的確聽別人念叨過,使用單緩沖渲染會導致某些瀏覽器在渲染時發(fā)生抖動。
不管怎么說,雙緩沖還是能夠避免最終用戶看到每個游戲?qū)ο笤诶L制過程中最后一幀的組合過程。在通過JavaScript執(zhí)行某些復雜繪制操作時(例如透明度、反鋸齒及可編程紋理),這種情況是完全可能發(fā)生的。
使用附加緩沖技術(shù)占用的內(nèi)存非常少,多執(zhí)行一次圖像復制操作(把后臺緩沖繪制到前臺緩沖)導致的性能損失也可以忽略不計,可以說實現(xiàn)雙緩沖系統(tǒng)沒有什么缺點。
如果將在HTML頁面中定義的畫布元素作為前臺緩沖,那就需要再創(chuàng)建一個畫布來充當后臺緩沖。為此,我們使用了document.createElement函數(shù)在內(nèi)存里創(chuàng)建了一個畫布元素,把它用作后臺緩沖。
- this.backBuffer = document.createElement('canvas');
- thisthis.backBuffer.width = this.canvas.width;
- thisthis.backBuffer.height = this.canvas.height;
- thisthis.backBufferContext2D = this.backBuffer.getContext('2d');
接下來,我們創(chuàng)建了ApplicationManager類的一個新實例,并調(diào)用startupApplicationManager來初始化它。這個ApplicationManager類將在下一篇文章中介紹。
- // 創(chuàng)建一個新的ApplicationManager
- this.applicationManager = new ApplicationManager().startupApplicationManager();
最后,使用setInterval函數(shù)重復調(diào)用draw函數(shù),這個函數(shù)是渲染循環(huán)的核心所在。
- // 使用setInterval來調(diào)用draw函數(shù)
- setInterval(function(){g_GameObjectManager.draw();}, SECONDS_BETWEEN_FRAMES);
#p#
下面來看一看draw函數(shù)。
- /**
- 渲染循環(huán)
- */
- this.draw = function ()
- {
- // 計算從上一幀到現(xiàn)在的時間
- var thisFrame = new Date().getTime();
- var dt = (thisFrame - this.lastFrame)/1000;
- this.lastFrame = thisFrame;
- // 清理繪制上下文
- this.backBufferContext2D.clearRect(0, 0, this.backBuffer.width, this.backBuffer.height);
- this.context2D.clearRect(0, 0, this.canvas.width, this.canvas.height);
- // 首先更新所有游戲?qū)ο?
- for (x in this.gameObjects)
- {
- if (this.gameObjects[x].update)
- {
- this.gameObjects[x].update(dt, this.backBufferContext2D, this.xScroll, this.yScroll);
- }
- }
- // 然后繪制所有游戲?qū)ο?
- for (x in this.gameObjects)
- {
- if (this.gameObjects[x].draw)
- {
- this.gameObjects[x].draw(dt, this.backBufferContext2D, this.xScroll, this.yScroll);
- }
- }
- // 將后臺緩沖復制到當前顯示的畫布
- this.context2D.drawImage(this.backBuffer, 0, 0);
- };
這個draw函數(shù)就是所有渲染循環(huán)的核心。在前面的例子中,渲染循環(huán)的函數(shù)會直接修改要繪制到屏幕上的對象(笑臉)。如果只需繪制一個對象,這樣做沒有問題。但是,一個游戲要由幾十個單獨的對象組成,所以這個draw函數(shù)并沒有直接在渲染循環(huán)中直接處理要繪制的對象,而是維護了一個保存著這些對象的數(shù)組,讓這些對象自己來更新和繪制自己。
首先,計算自上一幀渲染所經(jīng)過的時間。即便我們在代碼里寫了每秒鐘調(diào)用30次draw函數(shù),但誰也無法保證事實如此。通過計算自上一幀渲染所經(jīng)過的時間,可以做到盡可能讓游戲的執(zhí)行與幀速率無關(guān)。
- // 計算從上一幀到現(xiàn)在的時間
- var thisFrame = new Date().getTime();
- var dt = (thisFrame - this.lastFrame)/1000;
- this.lastFrame = thisFrame;
接著清理繪制上下文。
- // 清理繪制上下文
- this.backBufferContext2D.clearRect(0, 0, this.backBuffer.width, this.backBuffer.height);
- this.context2D.clearRect(0, 0, this.canvas.width, this.canvas.height);
然后,就是調(diào)用游戲?qū)ο?這些對象是由GameObject類定義的,下一篇文章將介紹該類)自己的更新(update)和繪制(draw)方法。注意,這兩個方法是可選的(這也是我們在調(diào)用它們之前先檢查它們是否存在的原因),但差不多每一個對象都需要更新和繪制自已。
- // 首先更新所有游戲?qū)ο?
- for (x in this.gameObjects)
- {
- if (this.gameObjects[x].update)
- {
- this.gameObjects[x].update(dt, this.backBufferContext2D, this.xScroll, this.yScroll);
- }
- }
- // 然后繪制所有游戲?qū)ο?
- for (x in this.gameObjects)
- {
- if (this.gameObjects[x].draw)
- {
- this.gameObjects[x].draw(dt, this.backBufferContext2D, this.xScroll, this.yScroll);
- }
- }
最后,把后臺緩沖復制到前臺緩沖,最終用戶就可以看到下一幀了。
- // 將后臺緩沖復制到當前顯示的畫布
- this.context2D.drawImage(this.backBuffer, 0, 0);
理解了draw函數(shù),下面再分別講一講addGameObject和removeGameObject函數(shù)。
- /**
- 向gameObjects集合中添加一個GameObject
- @param gameObject The object to add
- */
- this.addGameObject = function(gameObject)
- {
- this.gameObjects.push(gameObject);
- this.gameObjects.sort(function(a,b){return a.zOrder - b.zOrder;})
- };
- /**
- 從gameObjects集合中刪除一個GameObject
- @param gameObject The object to remove
- */
- this.removeGameObject = function(gameObject)
- {
- this.gameObjects.removeObject(gameObject);
- }
利用addGameObject和removeGameObject(在Utils.js文件里通過擴展Array.prototype添加)函數(shù),可以在GameObjectManager所維護的GameObject集合(即gameObjects變量)中添加和刪除游戲?qū)ο蟆?/p>
GameObjectManager類是我們這個游戲框架中最復雜的一個類。在下一篇文章中,我們會講解游戲框架的另外幾個類:GameObject、VisualGameObject、Bounce和ApplicationManager。
好了,現(xiàn)在放松一下,看一看Demo吧。
原文:http://www.brighthub.com/content/matthewcaspersonshubfoliohasmoved.aspx
【編輯推薦】