用canvas開發H5游戲小記


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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM