作者:Martin Wells
子麵畫基本原理 我一直很喜歡網頁遊戲,因為大多數都容易製作,而且容易玩(隻要點擊一個鏈接就可以開始玩了)。
Ajax和移動DOM元素是有些意思,但製約了你能製作的遊戲類型。對於遊戲開發者,技術不僅一直在變化,而且是飛速變化。HTML5為網頁遊戲開發不斷地提供大量新選擇,瀏覽器供應商也為成為新標準的最佳平台而展開激烈競爭。
sprite-animations
所以,從遊戲開發者的角度看,一切都朝著正確的方向發展:2D和3D硬件運算速度越來越快、javascript引擎的表現性能越來越好、排錯和分析工具高度集成,以及可能最重要的,瀏覽器供應商正在積極地角逐最佳網頁遊戲平台。
所以工具實用了,瀏覽器強大了,供應商重視了,我們就可以製作出優秀的遊戲了,對吧?基本上HTML5/Javascript遊戲開發仍然處於發展初期,會遇到許多誤區和技術選擇。
在本文中,我將介紹一些開發2D遊戲的選擇,但願能讓讀者對開發HTML5遊戲有所了解。
基礎 你要回答的第一個問題是,是使用HTML5 Canvas來繪製圖像(場景圖像)還是通過修改DOM元素。
為了用DOM做2D遊戲,你基本上要動態地調整元素風格,以便在頁麵上移動它。雖然有些時候DOM修改是很好的,但這一次我將重點介紹使用HTML5 Canvas來製作圖像,因為對於現代瀏覽器,它是最靈活的。
頁麵設置 首先,你要創建一個HTML頁麵,其中包含如下canvas標簽:
<!doctype html>
<html>
<head>
<title></title>
</head>
<body style=’position: absolute; padding:0; margin:0; height: 100%; width:100%’>
<canvas id=”gameCanvas”></canvas>
</body>
</html>
如果你載入以上代碼,當然什麼也不會出現。那是因為雖然我們有一個canvas標簽,但我們還沒在上麵繪製任何東西。我們來添加一些簡單的canvas命令來繪製小箱子吧。
<head>
<title></title>
<script type=’text/javascript’>
var canvas = null;
function onload() {
canvas = document.getElementById(‘gameCanvas’);
var ctx = canvas.getContext(“2d”);
ctx.fillStyle = ‘#000000′;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = ‘#333333′;
ctx.fillRect(canvas.width / 3, canvas.height / 3, canvas.width / 3,
canvas.height / 3);
}
</script>
</head>
<body onload=’onload()’ …
在這個例子中,我已經在body標簽中添加了一個onload事件,然後執行功能獲得畫布元素,並繪製幾個箱子。非常簡單。
result 1
這個箱子不錯,但你會注意到,畫布沒有鋪滿整個瀏覽器窗口。為了解決這個問題,我們可以增加畫布的寬度和高度。我是指根據畫布所包含的文件元素的大小來靈活地調整畫布尺寸。
var canvas = null;
function onload() {
canvas = document.getElementById(‘gameCanvas’);
canvas.width = canvas.parentNode.clientWidth;
canvas.height = canvas.parentNode.clientHeight;
…
加載後,你會看到畫布鋪滿整個屏幕了。太好了。
再進一步,如果瀏覽器窗口大小是由用戶調整的,我們還要重置畫布的尺寸。
var canvas = null;
function onload() {
canvas = document.getElementById(‘gameCanvas’);
resize();
}
function resize() {
canvas.width = canvas.parentNode.clientWidth;
canvas.height = canvas.parentNode.clientHeight;
var ctx = canvas.getContext(“2d”);
ctx.fillStyle = ‘#000000′;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = ‘#333333′;
ctx.fillRect(canvas.width/3, canvas.height/3, canvas.width/3, canvas.height/3);
}
添加onresize命令到body標簽。
<body onresize=’resize()’ …
現在,如果你調整瀏覽器的大小,矩形應該如下圖所示。
result 2
載入圖像 大部分遊戲都需要動畫的子畫麵,所以我來添加一些圖像吧。
首先,你需要圖像資源。因為我們要用javascript繪製它,所以我覺得先聲明圖像然後設置它的src屬性為你想載入的圖像的URL,比較合理。
var img = null;
function onload() {
…
img = new Image();
img.src = ‘simba.png’;
}
然後你可以通過添加這個到resize方法中來繪製圖像:
ctx.drawImage(img, canvas.width/2 – (img.width/2), canvas.height/2 – (img.height/2));
如果你重新載入頁麵後,在大部分情況下,你會看到圖像出現了。不過我說的是大部分情況下,因為這取決於你的機器跑得有多快、瀏覽器是否已經緩存了圖像。那是因為resize方法的調用時間介於你開始載入圖像(設置它的src屬性)的時間到瀏覽器準備好的時間之間。對於一兩張圖像,這個方法可能不錯,但當你的遊戲開始變大時,你就必須等到所有圖像加載完才能執行活動。
給圖像添加一個通知監聽器,這樣當圖像準備就緒時你就會收到回叫信號。我得重新整理一下,以下是更新過的代碼:
var canvas = null;
var img = null; var ctx = null;
var imageReady = false;
function onload() {
canvas = document.getElementById(‘gameCanvas’);
ctx = canvas.getContext(“2d”);
img = new Image();
img.src = ‘images/simba.png’;
img.onload = loaded();
resize();
}
function loaded() {
imageReady = true; redraw();
}
function resize() {
canvas.width = canvas.parentNode.clientWidth;
canvas.height = canvas.parentNode.clientHeight; redraw();
}
function redraw() {
ctx.fillStyle = ‘#000000′;
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (imageReady)
ctx.drawImage(img, canvas.width/2 – (img.width/2), canvas.height/2 – (img.height/2));
}
結果應該是:
result 3
這個圖像顯示了一隻吸血鬼貓(好吧,是我自己覺得像)的6個奔跑幀。為了把這個子畫麵做成動畫,我們必須每次繪製一個幀。
子畫麵動畫你可以用drawImage命令的源參數繪製一個幀。事實上,是隻繪製源圖像的一部分。所以為了繪製這唯一的第一幀,使用允許你指定源圖像中的矩形的drawImage的拓展版。因為我們的貓動畫是由6個96 x 96象素大小的幀組成的,我們可以添加:
ctx.drawImage(img, 0, 0, 96, 54, canvas.width/2 – 48, canvas.height/2 – 48, 96, 54);
這裏的關鍵是起點(0, 0, 96, 54)。這限製被繪製圖像為貓動畫的第一幀。我還設置根據單幀來居中,而不是包含所有6幀的整個圖像尺寸。
現在總算有點意思了。為了讓圖像動起來,我們必須追蹤要繪製的幀,然後隨著時間推進幀數。為此,我們必須把靜止頁麵做成隔時循環的頁麵。
我們按照老方法來做。添加60幀每秒間隔計時器。為了保證隻有圖像加載後才開始循環動畫,我們要在loaded功能中添加以下命令:
function loaded() {
imageReady = true;
setTimeout( update, 1000 / 60 );
}
添加更新後的函數,然後調用redraw:
var frame = 0;
function update() {
redraw(); frame++;
if (frame >= 6) frame = 0;
setTimeout( update, 1000 / 60 );
}
當繪製後且幀推進完,計時器就會重置。
下一步,調整繪製圖像,使源窗口根據我們想要繪製的那一幀位置來移動(關鍵是給幀設置的源X位置,是幀乘上幀的大小)。
function redraw() {
ctx.fillStyle = ‘#000000′;
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (imageReady)
ctx.drawImage(img, frame*96, 0, 96, 54,
canvas.width/2 – 48, canvas.height/2 – 48, 96, 54);
}
結果如下:
result 4
我們邪惡的不活吸血貓活了!跑得太快了。
我們還要對動畫做一些改進。
requestAnimFramesetTimeout很好,幾乎在所有瀏覽器上都運行得不錯,但還有一個更好的方法,那就是requestAnimFrame。
requestAnimFrame的作用基本上就是setTimeout,但瀏覽器知道你正在渲染幀,所以它可以優化繪製循環,以及如何與剩下的頁麵回流。它甚至會檢測標簽是否可見,如果隱藏就不繪製,這樣就節省了電池(是的,以60fps的速率循環的網頁遊戲是很燒電池的)。另外,瀏覽器還有機會以其他我們不知道的方式進行優化。根據我對更高級的幀加載的經驗,這樣可以大大提高表現,特別是在現在的瀏覽器中。
我要給讀者提個醒,在某些情況下,setTimeout比requestAnimFrame更好用,特別是對於手機。測試一下,根據設備配置一下你的應用。
在不同的瀏覽器上調用requestAnimFrame的情況也不同,標準的檢測方法如下:
window.requestAnimFrame = (function(){
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function( callback ){
window.setTimeout(callback, 1000 / 60);
};
})();
如果requestAnimFrame支持不可用,還是可以用回內置的setTimeout。
然後你必須修改update方法,以便重複獲得請求:
function update() {
requestAnimFrame(update);
redraw();
frame++;
if (frame >= 6) frame = 0;
}
在渲染/更新以前調用requestAnimFrame,往往能獲得更連貫的效果。
另外,當我第一次使用requestAnimFrame時,我試圖查找它如何計時的資料,但什麼也沒找到。那是因為它本來就是不能計時的。setTimeout沒有什麼與設置MS延時相當的東西,這意味著你不可能控製幀率。那你就做好你該做的事,其他的就讓瀏覽器去處理吧。
另一件要注意的事是,如果你封閉使用requestAnimFrame,那麼你必須做一個本地交換來調用它,如:
my.requestAnimFrame = (function () {
var func = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame || window.msRequestAnimationFrame ||
function (callback, element)
{
window.setTimeout(callback, 1000 / this.fps);
};
// apply to our window global to avoid illegal invocations (it’s a native) return function (callback, element) { func.apply(window, [callback, element]);
};
})();
基於時間的動畫 接下來我們要設置一下貓的奔跑速度。現在,動畫幀根據幀率播放,不同的設備情況有所不同。那就不妙了,因為如果角色移動的同時又有動畫,結果就會看起很來很怪很不協調。你可以試一下控製幀率,但根據真正的定時做出的動畫從各方麵看都更好些。
你還會發現,遊戲中的定時通常運用於你所做的一切東西:燃燒率、轉彎速度、加速、跳躍,使用合適的定時,都會有更好的效果。
為了讓貓以規定的速度奔跑,我們必須追蹤已經經過的時間,然後根據分配給每幀的時間播放幀。基本步驟是:
1、按每秒幾幀設置動畫速度(msPerFrame)。
2、當你循環遊戲時,計算一下自最後一幀以後已經經過了多少時間(delta)。
3、如果已經經過的時間足夠把動畫幀播完,那麼播放這一幀並設置累積delta為0。
4、如果已經經過的時間不夠,那麼記住(累積)delta時間(acDelta)。
以下是代碼:
var frame = 0;
var lastUpdateTime = 0;
var acDelta = 0;
var msPerFrame = 100;
function update() {
requestAnimFrame(update);
var delta = Date.now() – lastUpdateTime;
if (acDelta > msPerFrame)
{
acDelta = 0;
redraw();
frame++; if
(frame >= 6) frame = 0;
} else {
acDelta += delta;
}
lastUpdateTime = Date.now();
}
載入後,小貓的移動速度會更合理一些。
result 5
縮放和旋轉 當圖像渲染後,你還是可以使用這個2D畫布來執行各種操作,如旋轉和縮放。
例如,把圖像縮小一半。你可以通過添加ctx.scale(0.5, 0.5)來達到效果:
function redraw()
{
ctx.fillStyle = ‘#000000′;
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (imageReady)
{
ctx.save();
ctx.scale(0.5,0.5);
ctx.drawImage(img, frame*96, 0, 96, 54,
canvas.width/2 – 48, canvas.height/2 – 48, 96, 54);
ctx.restore();
}
}
result 6
你會發現我還在縮放命令前添加了ctx.save(),以及在最後添加了ctx.restore()。沒有這個,縮放命令就會累積,而可憐的小貓就會很快縮小到看不見(試一下,很有意思)。
使用負值還可以達到顛倒圖像的效果。如果你把縮放值從(0.5, 0.5)變成(-1, 1),那麼貓圖像就會水平翻轉,這樣它就會往相反的方向跑。注意,這個轉變是用翻轉起點X位置達到反轉圖像的效果。
function redraw() {
ctx.fillStyle = ‘#000000′;
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (imageReady) { ctx.save();
ctx.translate(img.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(img, frame*96, 0, 96, 54,
canvas.width/2 – 48, canvas.height/2 – 48, 96, 54);
ctx.restore();
}
}
你可以嚐試一下。以下是貓爬牆的動畫(其實是豎直旋轉了動畫):
ctx.rotate( 270*Math.PI/180 );
ctx.drawImage(img, frame*96, 0, 96, 54,
-(canvas.width/2 – 48), (canvas.height/2 – 48), 96, 54);
在這個例子中,通過旋轉內容,不隻是圖像旋轉了,連坐標也旋轉了,所以drawImage命令通過反轉貓繪製的X位置來抵消這個。
result 7
真是一隻天才的貓(不過吸血鬼本來就能爬牆)。
縮放和旋轉效果很好。好是好,但它也很慢,會對渲染表現產生重大影響。在製作遊戲時,還有另一個技巧——預渲染,可以解決這個問題以及你可能遇到了大量其他渲染表現問題。
預渲染 預渲染就是提前處理圖像。你隻做一次昂貴的渲染操作,然後循環使用已渲染好的結果。
在HTML5中,你必須在分開的不可見畫布上繪製,然後不是繪製圖像,而是把其他畫布繪製在圖像的位置上。
以下是預渲染貓的代碼例子:
var reverseCanvas = null;
function prerender() {
reverseCanvas = document.createElement(‘canvas’);
reverseCanvas.width = img.width;
reverseCanvas.height = img.height;
var rctx = reverseCanvas.getContext(“2d”);
rctx.save(); rctx.translate(img.width, 0);
rctx.scale(-1, 1);
rctx.drawImage(img, 0, 0);
rctx.restore();
}
注意,畫麵對象是創建的,不是添加到文件占的,所以它不會顯示出來。高度和寬度設置到原來的子畫麵表格中,然後原圖像會使用渲染器的2D環境繪製圖像。
為了設置預渲染,你可以從loaded功能中調用它。
function loaded() {
imageReady = true;
prerender();
requestAnimFrame(update);
}
然後當你製作定期重繪製命令時,使用reverseCanvas而不是原來的畫布:
function redraw() {
ctx.fillStyle = ‘#000000′;
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (imageReady) {
ctx.save();
ctx.drawImage(reverseCanvas, frame*96, 0, 96, 96,
(canvas.width/2 – 48), (canvas.height/2 – 48), 96, 96);
ctx.restore();
}
}
不幸地是,當我們顛倒圖像,動畫也會往後播放,所以你必須把動畫順序也顛倒一下:
function update() {
requestAnimFrame(update);
var delta = Date.now() – lastUpdateTime;
if (acDelta > msPerFrame) {
acDelta = 0;
redraw();
frame–;
if (frame < 0) frame = 5;
} else {
acDelta += delta;
}
lastUpdateTime = Date.now();
}
result 8
如果有需要,你可以把畫麵轉換成圖像,即設置它的來源為使用包含編碼圖像數據的數據URL。畫布有方法可以達到這個效果,所以代碼很簡單:
newImage = new Image();
newImage.src = reverseCanvas.toDataURL(“image/png”);
另一個有意思的圖像操作是使用真正的象素數據。HTML5畫布元素把圖像數據當作RGBA格式的象素集合來顯示。代碼如下:
var imageData = ctx.getImageData(0, 0, width, height);
上述代碼會返回一個包含寬度、高度和數據成員的ImageData結構。這個數據元素就是一個象素的集合。
這個數據組是由所有象素點組成的,每個象素點都表現為4個實體,紅、綠、藍和alpha通道層,色彩範圍是0-255。因此一張寬和高都是512的圖像形成的數組就包含1048576個元素,也就是512×512等於262144個象素點再乘上4(每個象素點是4個實體)。
使用這個數據組,這裏有一個例子:圖像的特殊紅色成分增加而紅色和藍色成分減少,因此形成我們的2級怪物——地獄惡魔貓。
function prerender() {
reverseCanvas = document.createElement(‘canvas’);
reverseCanvas.width = img.width;
reverseCanvas.height = img.height;
var rctx = reverseCanvas.getContext(“2d”);
rctx.save();
rctx.translate(img.width, 0);
rctx.scale(-1, 1);
rctx.drawImage(img, 0, 0);
// modify the colors var imageData = rctx.getImageData(0, 0, reverseCanvas.width, reverseCanvas.height);
for (var i=0, il = imageData.data.length;
i < il; i+=4) {
if (imageData.data[i] != 0) imageData.data[i] = imageData.data[i] + 100;
// red
if (imageData.data[i+1] != 0) imageData.data[i+1] = imageData.data[i+1] – 50;
// green
if (imageData.data[i+1] != 0) imageData.data[i+2] = imageData.data[i+2] – 50;
// blue
}
rctx.putImageData(imageData, 0, 0);
rctx.restore();
}
這個for循環有4次,每一次都修改這3個主色。第4個通道,alpha保持不變,但如果你希望可以使用它變化某些象素的透明度。(注:在下麵的例子中,我們給圖像數據使用一個dataURL,主要是為了避免直接修改象素產生交叉域名問題。你不必在自己的服務器上做這個。)
因為使用象素組修改圖象需要重製所有元素,在這個地獄貓的例子中,超過100萬次,你應該盡量提前計算,盡量不要製作變量/對象和跳過象素。
結論將畫布繪製、縮放、旋轉、轉換和象素修改相結合,再加上預渲染,製作出來的遊戲的動態效果非常棒。
我最近在一款2D四方向橫版太空射擊的遊戲《Playcraft》的DEMO中也使用了這些技術。美工隻給每種飛船(玩家和敵人)製作一個幀,之後我再根據我們希望飛船轉向的角度、流暢程度來旋轉和預渲染飛船。我可以在運行時根據飛船類型修改角度——殖家飛船的默認轉向角度是36度(非常流暢),而敵人和對手飛船的是16度(比較卡)。我還添加了一個選項,允許電腦性能比較好的玩家把角度提高到72(最流暢)。另外,飛船的徽章和標誌會根據你所在的隊伍動態地重新著色。這再一次節省了渲染和資源,而且允許飛船顏色根據玩家選擇的隊伍動態地調整。