用canvas開發H5遊戲小記

呂大豹發表於2014-09-19
  自神經貓風波之後,微信中的各種小遊戲如雨後春筍般目不暇接,這種低成本,高效傳播的案例很是受開發者青睞。作為一名前端,隨手寫個這樣的小遊戲出來應該算是必備技能吧。恰逢中秋節,部門決定上線一個小遊戲,在微信裡傳播一下與使用者互動互動。這任務自然落在了我頭上。前段時間用DOM+CSS3寫了個小遊戲,在Android機器上巨卡無比,有了上次的經驗,這次決定用canvas來寫。其實這些小遊戲在業界也都是canvas來做,已經有很成熟的技術和框架,由於不會頻繁修改DOM樹,所有的動畫都是在一塊畫布上完成,所以在手機上的效果比DOM要優秀很多。
  樓主本人用canvas做遊戲的經驗為0,只在大學的時候鼓搗過一次,知識全部忘卻了。這次也是邊學邊做,鑑於遊戲邏輯比較簡單,鼓搗了一天,終於搞出一個能玩的了。在此把實現原理記錄一下。也給像我這樣的初上手的一些參考資料。
  先來說說這個遊戲,名字叫玉兔吃月餅,很有中秋的氛圍哈~玩法非常簡單,用手指觸控螢幕來控制一隻開著飛碟的兔子移動,天上會不停掉月餅,有好月餅和壞月餅之分,吃到好月餅就得分,吃到壞月餅就掛掉。主要邏輯就這麼簡單。看一下游戲的截圖:
  遊戲demo在這裡,點選試玩。
  下面就把整個遊戲的實現細節來說一下,其實整體來看還是沒有什麼難度的。
 
遊戲舞臺的尺寸
     先從最基本的來說起。遊戲的舞臺就是我們的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);
          });
     }
}
View Code
  程式碼是一目瞭然的,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);
     }
}
View Code
  另外還有一點要說的是,月餅的速度是在不斷增加的,以此來控制遊戲的難道逐漸增高。定義一個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);
                         }
                    }
               }
              
          }
     }
View Code
呼叫的時候,我們把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++;
     }
View Code
首先我們會執行一次canvas的clearRect方法來把畫布清空一下,否則畫面會重疊上去。之後繪製背景、飛船、月餅。呼叫相關的動畫方法後,整個遊戲就動起來了~
     其實在這裡我開發的時候遇到了一個糾結的地方,那就是用setTimeout來控制幀重新整理,在上篇文章中,我有介紹用requestAnimationFrame也是可以控制幀重新整理的,寫這個小遊戲的時候我一開始也是用了這個方法,但是在測試的時候遇到了一個現象,在iphone4上,當用手指控制飛船移動的時候,幀率就有明顯的下降,我不清楚是什麼原因造成,後來看別人程式碼中是setTimeout的,就抄了過來解決問題。所以在此我也丟擲一個問題:setTimeout與requestAnimationFrame到底該選擇哪個,是否與canvas有關,有大牛知道也望請指點。
 
總結一下
     通過以上幾個步驟,遊戲的基本功能就完成了,其他一些遊戲流程控制,包括開始、結束、得分計算等在此就不敘述了。總體感覺用canvas做一個小遊戲的難度也不算大,不過我寫的這個遊戲也確實特別簡單,可以作為入門的例子。
     這次當做多原生canvas的一次學習,以後做遊戲的話,我也不打算用原生canvas了,準備學習下cocos2d-js,最近也釋出了其正式版本,正是上手的最佳時間。
     本遊戲的原始碼扔在了github上:https://github.com/Double-Lv/tuzibenyue
  本文倉促完成,有些觀點和寫法可能不正確,如有問題,歡迎留言指導~

相關文章