自神經貓風波之后,微信中的各種小游戲如雨后春筍般目不暇接,這種低成本,高效傳播的案例很是受開發者青睞。作為一名前端,隨手寫個這樣的小游戲出來應該算是必備技能吧。恰逢中秋節,部門決定上線一個小游戲,在微信里傳播一下與用戶互動互動。這任務自然落在了我頭上。前段時間用DOM+CSS3寫了個小游戲,在Android機器上巨卡無比,有了上次的經驗,這次決定用canvas來寫。其實這些小游戲在業界也都是canvas來做,已經有很成熟的技術和框架,由於不會頻繁修改DOM樹,所有的動畫都是在一塊畫布上完成,所以在手機上的效果比DOM要優秀很多。
樓主本人用canvas做游戲的經驗為0,只在大學的時候鼓搗過一次,知識全部忘卻了。這次也是邊學邊做,鑒於游戲邏輯比較簡單,鼓搗了一天,終於搞出一個能玩的了。在此把實現原理記錄一下。也給像我這樣的初上手的一些參考資料。
先來說說這個游戲,名字叫玉兔吃月餅,很有中秋的氛圍哈~玩法非常簡單,用手指觸控屏幕來控制一只開着飛碟的兔子移動,天上會不停掉月餅,有好月餅和壞月餅之分,吃到好月餅就得分,吃到壞月餅就掛掉。主要邏輯就這么簡單。看一下游戲的截圖:

游戲demo在這里,點擊試玩。
View Code
View Code
View Code
View Code
下面就把整個游戲的實現細節來說一下,其實整體來看還是沒有什么難度的。
游戲舞台的尺寸
先從最基本的來說起。游戲的舞台就是我們的canvas元素,這個元素的尺寸應該如何設置呢?既然要適配各種手機屏幕,那我在css中給它寬高都設為100%不就可以嘍。其實這個朴素的想法是錯誤的,canvas元素的使用與普通的html元素並不相同,它有一個默認尺寸300*150,在css中設置寬高只能改變canvas的顯示寬高,而並沒有改變畫布繪制時的尺寸,所以要為canvas設置繪制的尺寸,必須寫在元素標簽上。例如,我的canvas元素是這樣的:
<div id="gamepanel"> <canvas id="stage" width="320" height="568"></canvas> </div>
這里你可能要問了,為什么尺寸是320*568呢?這里有必要說一下,我們在做手機端頁面時,給iPhone的容器寬度是320px,給Android的容器寬度是360px,這里想要兼容兩者,所以只能取最小的320了,否則iPhone出現滾動條是很蛋疼的。至於安卓設備,我們只能委屈它一下了,給一個較窄的寬度,然后讓整個容器居中對齊,游戲容器的樣式如下:
#gamepanel{ width: 320px; margin: 0 auto; height: 568px; position: relative; overflow: hidden; }
寬度整好了,那height值為568又是什么原因呢?其實我也沒有研究過,只是看到別人代碼里這么寫,就抄過來了。
就在樓主寫這篇文章的時候,看到了cocos2d-js生成的canvas是這樣的規則:
Android設備:360*640
iphone5:320*568
iphone4:320*480
所以以后在不用框架的時候,可以用js判斷來確定canvas的尺寸,這里算是學到的一點小知識。
滾動的背景
我們有一張天空的圖片來做背景,並且要不停向下移動,這樣感覺飛碟在不停的向前飛行。如何讓背景圖片連續不間斷的移動呢?
首先定義了一個全局對象gameMonitor,游戲控制需要用到的參數、方法,都定義為它的屬性,用來組織游戲的整體邏輯。其中,滾動背景的函數rollBg定義如下:
rollBg : function(ctx){ if(this.bgDistance>=this.bgHeight){ this.bgloop = 0; } this.bgDistance = ++this.bgloop * this.bgSpeed; ctx.drawImage(this.bg, 0, this.bgDistance-this.bgHeight, this.bgWidth, this.bgHeight); ctx.drawImage(this.bg, 0, this.bgDistance, this.bgWidth, this.bgHeight); },
有兩個變量bgloop和bgDistance分別記錄背景的重繪次數和移動了的距離,每次重繪讓bgloop自增,乘以速度就是新的距離。為了實現背景圖片的無縫滾動,我們需要調用兩次drawImage來繪制兩張圖片上去,繪制的位置關系如下圖所示:

這樣才能保證背景滾動的過程中不會閃爍也不會中斷。
簡易的圖片加載器
由於游戲一開始並沒有把所有的圖片都加載下來,所以后續要用到的圖片,比如飛碟、兔子都是需要延遲加載進來的,所以需要實現一個圖片加載器,大體的功能就是讓瀏覽器加載圖片,然后在別的代碼中調用可以直接使用圖片,代碼如下:
function ImageMonitor(){ var imgArray = []; return { createImage : function(src){ return typeof imgArray[src] != 'undefined' ? imgArray[src] : (imgArray[src] = new Image(), imgArray[src].src = src, imgArray[src]) }, loadImage : function(arr, callback){ for(var i=0,l=arr.length; i<l; i++){ var img = arr[i]; imgArray[img] = new Image(); imgArray[img].onload = function(){ if(i==l-1 && typeof callback=='function'){ callback(); } } imgArray[img].src = img } } } }
返回的對象有兩個方法,第一個createImage,返回當前數組中對應的圖片,如果不存在該圖片,則new一個來返回。第二個loadImage接收一個數組和一個回調函數,把數組中的圖片路徑逐一加載,保存到一個數組中,最后一張圖片加載完后執行一個回調函數。
這段代碼其實是我從別人代碼中偷來的,稍一推敲,就會發現這段代碼其實是有問題的:
1. createImage方法,在當前imgArray數組中有所需圖片時是沒問題的,但是如果沒有就需要現加載,在別的地方如果調用了這個方法,那么后面的代碼應該是放在img的onload函數中執行才對,否則一旦網絡較慢,這個時候可能圖片還未加載下來,后續代碼會報錯。
2. loadImage方法,回調函數的執行是在最后一張圖片的onload函數中執行,這也是有可能出問題的,因為瀏覽器是可以並發請求的,有可能最后一張圖片已經加載完了,前面的圖片還沒加載完(最后一張圖片較小,前面的較大,或者是網絡的原因),這個時候執行回調的時機也是不准確的。
開發的時候因為時間緊急我沒有改良這段代碼,只是避開了可能出問題的用法。那么標准的加載圖片,或者說資源管理應該是如何進行呢?我相信業界已經有了標准答案,后續我會搞清楚這個問題。以后寫游戲就用框架(像cocos2d-js)來管理這些了,原生的要顧及的東西實在是多。
實現飛船的繪制、操控
接下來就開始實現游戲的主體,飛船。用js面向對象的寫法(大家都這么叫,姑且這么叫吧),我們編寫一個Ship類,屬性有寬高、坐標、游戲圖片,有一個paint方法來把自己繪制出來,還有一個controll方法來響應用戶的操作,代碼如下:

function Ship(ctx){ gameMonitor.im.loadImage(['static/img/player.png']); this.width = 80; this.height = 80; this.left = gameMonitor.w/2 - this.width/2; this.top = gameMonitor.h - 2*this.height; this.player = gameMonitor.im.createImage('static/img/player.png'); this.paint = function(){ ctx.drawImage(this.player, this.left, this.top, this.width, this.height); } this.setPosition = function(event){ this.left = event.changedTouches[0].clientX - this.width/2 - 16; this.top = event.changedTouches[0].clientY - this.height/2; if(this.left<0){ this.left = 0; } if(this.left>320-this.width){ this.left = 320-this.width; } if(this.top<0){ this.top = 0; } if(this.top>gameMonitor.h - this.height){ this.top = gameMonitor.h - this.height; } this.paint(); } this.controll = function(){ var _this = this; var stage = $('#gamepanel'); var currentX = this.left, currentY = this.top, move = false; stage.on('touchstart', function(event){ _this.setPosition(event); move = true; }).on('touchend', function(){ move = false; }).on('touchmove', function(event){ event.preventDefault(); _this.setPosition(event); }); } }
代碼是一目了然的,paint方法是基礎,setPosition其實就是修改飛船的left和top值,並防止移出屏幕,每次移動完后調用paint方法來重現繪制飛船。controll方法則是監聽了touch事件,計算得出新的位置。
實現月餅的繪制、移動
實現了Ship類,接下來該實現月餅了,我們定義為Food類。與Ship類有些不同,Food的示例會有很多個,因為天上在不停掉月餅嘛,而且月餅有好壞之分,所以Food類多了兩屬性:id和type,用來標識月餅和它的類型。另外,由於Food類會new很多實例出來,所以方法我們定義在prototype上,這樣減少每次創建實例時的內存消耗。代碼如下:

function Food(type, left, id){ this.speedUpTime = 300; this.id = id; this.type = type; this.width = 50; this.height = 50; this.left = left; this.top = -50; this.speed = 0.04 * Math.pow(1.2, Math.floor(gameMonitor.time/this.speedUpTime)); this.loop = 0; var p = this.type == 0 ? 'static/img/food1.png' : 'static/img/food2.png'; this.pic = gameMonitor.im.createImage(p); } Food.prototype.paint = function(ctx){ ctx.drawImage(this.pic, this.left, this.top, this.width, this.height); } Food.prototype.move = function(ctx){ if(gameMonitor.time % this.speedUpTime == 0){ this.speed *= 1.2; } this.top += ++this.loop * this.speed; if(this.top>gameMonitor.h){ gameMonitor.foodList[this.id] = null; } else{ this.paint(ctx); } }
另外還有一點要說的是,月餅的速度是在不斷增加的,以此來控制游戲的難道逐漸增高。定義一個speedUpTime 作為加速的時間間隔,默認為300,游戲的幀率為60,所以每隔5秒就會進行一次加速。新創建的月餅實例在初始化的時候,它的速度要和當前屏幕上的月餅速度一致,所以這個speed是動態的,有一個計算公式。
隨機產生月餅
有了Food類后,只要我們調用new Food(type, left ,id),就會創建出一個月餅。接下來,我們需要在屏幕上以一定的頻率隨機產生月餅。在gameMonitor中定義一個genorateFood方法,讓它來管理月餅的生成,代碼如下:
genorateFood : function(){ var genRate = 50; //產生月餅的頻率 var random = Math.random(); if(random*genRate>genRate-1){ var left = Math.random()*(this.w - 50); var type = Math.floor(left)%2 == 0 ? 0 : 1; var id = this.foodList.length; var f = new Food(type, left, id); this.foodList.push(f); } }
月餅產生頻率genRage默認為50,即不到1秒的時間產生一個月餅,根據實際測試,這個值比較合適。然后把new出來的月餅實例push到gameMonitor的FoodList數組中。FoodList中保存着當前屏幕上的所有月餅,這樣,我們每次重繪canvas的時候,只要把foodList中的月餅挨個繪制出來就OK了,同樣的道理,當有月餅移出屏幕,或者是被吃掉時,把它從FoodList中刪除就OK了。
兔子吃月餅
兔子有了,月餅有了,接下來就該吃了。我們給Ship類添加一個eat方法,表示吃月餅。所謂吃月餅說白了還是做碰撞檢測,每次幀刷新的時候,讓飛碟與界面上所有的月餅做一次碰撞檢測,如果發生了碰撞,判斷月餅的類型,好月餅則得分加一,壞月餅則游戲結束。因為飛碟和月餅都是近似圓形,所以按照圓形模型來做碰撞檢測就再簡單不過了,兩圓心的距離小於半徑之和,則認為發生了碰撞。Ship的eat方法定義如下:

this.eat = function(foodlist){ for(var i=foodlist.length-1; i>=0; i--){ var f = foodlist[i]; if(f){ var l1 = this.top+this.height/2 - (f.top+f.height/2); var l2 = this.left+this.width/2 - (f.left+f.width/2); var l3 = Math.sqrt(l1*l1 + l2*l2); if(l3<=this.height/2 + f.height/2){ foodlist[f.id] = null; if(f.type==0){ gameMonitor.stop(); $('#gameoverPanel').show(); setTimeout(function(){ $('#gameoverPanel').hide(); $('#resultPanel').show(); gameMonitor.getScore(); }, 2000); } else{ $('#score').text(++gameMonitor.score); $('.heart').removeClass('hearthot').addClass('hearthot'); setTimeout(function() { $('.heart').removeClass('hearthot') }, 200); } } } } }
調用的時候,我們把gameMonitor維護的foodList數組傳進來即可。同時要注意,當一個月餅被吃掉后,要從該數組中移除,這樣下一幀就不會把它繪制出來了。
讓游戲run起來
我們該定義的東西也都差不多了,接下來是讓游戲跑起來的時候了!所謂的跑起來,就是讓canvas不停的重繪而已,在gameMonitor上定義一個方法run,通過setTimeout來遞歸調用它,延時時間為1000/60,這樣可以維持幀率在60。run方法定義如下:

run : function(ctx){ var _this = gameMonitor; ctx.clearRect(0, 0, _this.bgWidth, _this.bgHeight); _this.rollBg(ctx); //繪制飛船 _this.ship.paint(); _this.ship.eat(_this.foodList); //產生月餅 _this.genorateFood(); //繪制月餅 for(i=_this.foodList.length-1; i>=0; i--){ var f = _this.foodList[i]; if(f){ f.paint(ctx); f.move(ctx); } } _this.timmer = setTimeout(function(){ gameMonitor.run(ctx); }, Math.round(1000/60)); _this.time++; }
首先我們會執行一次canvas的clearRect方法來把畫布清空一下,否則畫面會重疊上去。之后繪制背景、飛船、月餅。調用相關的動畫方法后,整個游戲就動起來了~
其實在這里我開發的時候遇到了一個糾結的地方,那就是用setTimeout來控制幀刷新,在上篇文章中,我有介紹用requestAnimationFrame也是可以控制幀刷新的,寫這個小游戲的時候我一開始也是用了這個方法,但是在測試的時候遇到了一個現象,在iphone4上,當用手指控制飛船移動的時候,幀率就有明顯的下降,我不清楚是什么原因造成,后來看別人代碼中是setTimeout的,就抄了過來解決問題。所以在此我也拋出一個問題:setTimeout與requestAnimationFrame到底該選擇哪個,是否與canvas有關,有大牛知道也望請指點。
總結一下
通過以上幾個步驟,游戲的基本功能就完成了,其他一些游戲流程控制,包括開始、結束、得分計算等在此就不敘述了。總體感覺用canvas做一個小游戲的難度也不算大,不過我寫的這個游戲也確實特別簡單,可以作為入門的例子。
這次當做多原生canvas的一次學習,以后做游戲的話,我也不打算用原生canvas了,准備學習下cocos2d-js,最近也發布了其正式版本,正是上手的最佳時間。
本游戲的源代碼扔在了github上:
https://github.com/Double-Lv/tuzibenyue
本文倉促完成,有些觀點和寫法可能不正確,如有問題,歡迎留言指導~