自神經貓風波之後,微信中的各種小遊戲如雨後春筍般目不暇接,這種低成本,高效傳播的案例很是受開發者青睞。作為一名前端,隨手寫個這樣的小遊戲出來應該算是必備技能吧。恰逢中秋節,部門決定上線一個小遊戲,在微信裡傳播一下與使用者互動互動。這任務自然落在了我頭上。前段時間用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
本文倉促完成,有些觀點和寫法可能不正確,如有問題,歡迎留言指導~