用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
  本文倉促完成,有些觀點和寫法可能不正確,若有問題,歡迎留言指導~
相關文章
相關標籤/搜索