DOM+CSS3實現小遊戲SwingCopters

呂大豹發表於2014-09-12

  前些日子看到了一則新聞,flappybird原作者將攜新遊戲SwingCopters來襲,準備再靠這款姊妹篇遊戲引爆大眾眼球。就是下面這個小遊戲:

   

  前者的傳奇故事大家都有耳聞,至於這第二個遊戲能否更加火爆那是後話了。不過我看了作者的宣傳視訊後,蠢蠢欲動,這麼簡單的小遊戲我山寨一個網頁版出來如何?簡單思索一下,打算用DOM+CSS3來實現一個。一來強化一個下自己的CSS3知識,二來也探索下用原生DOM來做動畫的效能到底如何。
  三四天後,原作者的SwingCopters貌似沒怎麼火起來,看來flappybird的神話只是一個偶然呀~不過我的山寨版倒是有模有樣的做出來了,點這裡檢視Demo,請在chrome下開啟, 你懂的。
  先來說下整體思路,基本的動畫效果,如移動、旋轉,用CSS3的transition、transform+keyframes來做,把基本的動畫單元做成一個個css類,為元素新增對應的class就可以讓它動起來,刪除、更改class則可以讓元素停止、切換動畫。至於什麼時候進行切換,一方面是根據使用者的操作,另一方面是根據遊戲的“主執行緒”來判斷。所謂“主執行緒”,就是控制遊戲畫面不停重新整理的程式碼,遊戲的主控制邏輯都寫在這裡,包括場景生成、碰撞檢測等。大家都知道動畫是由頁面的不停重繪來產生的,當每秒的重新整理次數達到60時,人眼會感覺到流暢的動畫,這也是大多數遊戲追求60fps的原因。關於如何做幀重新整理有幾種方法,具體可參看這裡(http://qingbob.com/javascript-high-performance-animation-and-page-rendering/)。我這裡採用requestAnimationFrame來做,它的好處是讓你用程式碼來請求一次幀重新整理,這樣能避免“掉幀”,但是負面影響是,當機器效能不好時,會降低幀率,表現就是你看到遊戲的動畫變緩慢了。
     requestAnimationFrame在PC端的支援還不錯,不過在移動端的就有點挫了,Android4.4才支援,所以有必要做一下相容處理,幸好已經有大神提供程式碼了,直接拿來用:
(function(){
    var lastTime = 0;
    var vendors = ['ms', 'moz', 'webkit', 'o'];
    for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
        window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame'];
        window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame'] || window[vendors[x] + 'CancelRequestAnimationFrame'];
    }
    if (!window.requestAnimationFrame) window.requestAnimationFrame = function(callback, element) {
        var currTime = new Date().getTime();
        var timeToCall = Math.max(0, 16 - (currTime - lastTime));
        var id = window.setTimeout(function() {
            callback(currTime + timeToCall);
        }, timeToCall);
        lastTime = currTime + timeToCall;
        return id;
    }
    if (!window.cancelAnimationFrame) window.cancelAnimationFrame = function(id) {
        clearTimeout(id);
    }
}());

  下面我把一些技術細節來介紹下,介於小弟也是第一次做遊戲,有些地方的實現不免走了彎路,或者損耗效能,有大牛發現了請一定賜教~

自適應的容器
     先從最簡單的來說起吧,首先需要一個div來做整個遊戲的容器,由於遊戲要能在手機上玩,所以寬高就必須做成自適應的,那麼viewport的設定是必不可少的:
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
  這個不多解釋了。div預設寬度100%所以不用管,高度要做到根據螢幕100%顯示,我們需要給文件的根節點這樣的css程式碼:
html, body{
     height: 100%;
     position: relative;
     margin: 0;
     overflow: hidden;
     -webkit-user-select:none;
}
  高度100%。定位屬性relative,讓子元素的定位以它為參照。同時overflow:hidden防止出現滾動條。最後還加了user-select:none,防止使用者連續點選的時候出現難看的選區。
     接下來是容器container的樣式:
#container{
     height: 100%;
     position: relative;
     overflow: hidden;
}
  這樣高度就能充滿整個螢幕了。
     另外,為了讓遊戲在PC瀏覽器中也可以玩,我又用媒體查詢做了如下設定:
@media screen and (min-width: 1024px) {
     #container{
          width: 360px;
          margin: 0 auto;
     }
}
  給容器360畫素的寬度並居中對齊。這樣在PC瀏覽器中就不會拉伸的很難看了。
移動的背景
     遊戲的容器container有一個背景圖片,這個背景圖片是需要連續且無限滾動的。首先,圖片縱向平鋪嘛,一個background-repeat: repeat-y;搞定。原先我考慮這麼簡單的運動用css3肯定能做的啦,但細細考慮之後發現竟然實現不了。。。假設在keyframes中設定關鍵幀,改變background-position來實現背景移動,移動倒是沒問題,關鍵是這個連續無限滾動比較棘手,要連續滾動必須給一個很大的值才行,background-position需要設為多大才算無限呢?天知道玩家能玩多長時間,而且這樣做顯然是不合理的。或者把動畫的播放次數設為infinite呢?這也不行,因為每次迴圈都會從頭開始播放一遍,這樣背景會閃動。所以最終還是把背景的移動放在js中來操作了,用一個變數來記錄背景的位置,然後在主執行緒中不斷遞增。大概的程式碼結構是這樣子的:
var game = {
     bgMove : function(){
          posMark += 2;
          container.css('background-position', '0 '+posMark+'px');
          timmer = requestAnimationFrame(game.bgMove);
     }
}
  只要呼叫game.bgMove(),就會通過 requestAnimationFrame來遞迴呼叫,用一個全域性的變數來標記背景的位置,每次遞增,從而不斷修改背景位置,實現背景無限移動。
逐步播放動畫實現旋轉的螺旋槳
     遊戲中人物頭上的螺旋槳在不停轉動,如何實現這個動畫呢?其實原理很簡單,我們只需準備這樣一張圖片:
  
  這是向左飛行和向右飛行的幾個狀態,將它設定為背景圖片,然後不停改變背景的位置即可。要注意的是背景位置並不是連續變化,而是在幾個值之間“切換”。
     css3的keyframes + animation是通過定義關鍵幀的方式來實現動畫,像flash一樣,幀之間的過渡效果由瀏覽器來替你完成。但我們此處並不想要過渡效果,我們只想讓播放兩個幀而已。這裡要用到animate-timing-function的一個比較特殊的取值:steps(),它可以控制動畫最終由多少步來完成。這裡我們需要圖片中的第一個狀態和第二個狀態來切換,所以取steps(2)就OK了。程式碼如下:
  首先我們定義關鍵幀:
@-webkit-keyframes flyr{
     0%{
          background-position: 0 0;
     }
     100%{
        background-position: -108px 0;
     }
}
  然後定義一個class,只要在元素上加上這個類就可以進行動畫了:
.flyr{
     -webkit-animation:flyr 200ms steps(2) 0 infinite;
}
  我直接使用了animation這個混合屬性,取值的含義依次是:animation-name(動畫名稱),animation-duration(動畫時間),animation-delay(開始播放時間),animation-iteration-count(播放次數),animation-direction(播放方向),animation-fill-mode(播放後的狀態),animation-play-state(設定動畫的狀態),不寫則取預設值。
     來看一下效果吧:
 
 
  
  向左飛的動畫也同理,改變background-position的值即可。我們取名為flyl,只需要讓元素的類名在flyl和flyr直接切換,就可以改變飛行的方向,是不是很方便。
     在這裡需要注意的一點是,steps(2)控制的兩步播放,並不是播放0%和100%時的狀態,而是根據具體的css屬性的值來計算最終播放的兩幀是什麼狀態。你可以自己寫個例子看一下,這裡不多說了。
起步向上飛行
     人物一開始是在地上站著的,遊戲開始時會先上升到半空中,然後垂直位置不再改變。這個比較好做,我們只需定義一個名為up的動畫,如下:
@-webkit-keyframes up{
     0%{
          bottom: 0;
     }
     100%{
          bottom: 44%;
     }
}
  然後一塊加在flyl類上即可,多個動畫用逗號隔開。於是flyl就變成了這樣:
.flyl{
    -webkit-animation:flyl 200ms steps(2) 0 infinite, up 4s linear 0 1 normal forwards;
}
  這裡animation-iteration-count取值為1,因為只播放一次就可以了。另外要注意的一點是,這一遍播放完後動畫應該停留在結束時的狀態,所以我們還需設定animation-fill-mod值為forwards。
人物的左右移動
     通過點選改變了飛行方向後,人物會向對應的方向橫向移動,這個怎麼來做呢?一開始我想簡單了,左右移動嘛,跟上升還不是一個道理?於是想當然的定義一個這樣的動畫:
@-webkit-keyframes mover{
     0%{
          left : 0;
      }
     100%{
          left : 100%;
     }
}
  只需在flyl後面再加個逗號,加上movel就行了。或者定義成一個類,為人物新增這個類來實現向左移動。
     但事實證明這樣是錯誤的。因為在實際操作中,改變飛行方向可能發生在任何一刻,而這個時候人物的left值可能是20、50或者其他任何值。我們需要的是在當前left的基礎上進行改變,而不是讓它先歸零。所以這裡便不能用keyframes了,因為我們總是無法確定這個初始的left是多少。
     這個時候css3的transition就派上用場了,它的作用也是自動建立補間動畫,只不過沒有animation那麼複雜,只需為它指明需要過渡哪些屬性就可以了。所以,我的flyl和flyr就變成了這樣:
.flyl{
     left: 0 !important;
     -webkit-animation:flyl 200ms steps(2) 0 infinite, up 4s linear 0 1 normal forwards;
}
.flyr{
     left: 100% !important;
     -webkit-animation:flyr 200ms steps(2) 0 infinite, up 4s linear 0 1 normal forwards;
}   
  與此同時,我們的player要加上這一行:
-webkit-transition : left 1.5s 0 linear;
  這樣我們巧妙的擺脫了之前的困境,只需指定left即可,管它是從哪個值變來的,交給transition過渡去就好了。
     現在只要監聽click事件,根據玩家的點選來為人物切換class,我們的就可以來回飛了。js程式碼如下:
$(document).on('click', function(){
               if(++direction%2==0){
                    player[0].className = 'flyl';
               }
               else{
                    player[0].className = 'flyr';
               }
          });
  我們用一個變數direction來記錄當前的方向,每次點選讓它遞增,然後根據奇偶性來改變className即可。之所以用變數來記錄而不是通過hasClass來判斷當前方向的原因是減少DOM訪問。
擺錘的產生和移動
     先說擺錘的左右擺動動畫,這個其實也不難,用transform:rotate控制旋轉一定的角度即可。有一點要注意的是,transform的變形圓點預設是元素的中心位置,而我們的擺錘可不是原地旋轉的,所以旋轉的中心應該控制在元素的頂部位置,我們用transform-origin來設定變形圓點位置,程式碼如下:
-webkit-transform-origin:center 4px;
  擺錘是掛在橫樑上的,橫樑是自上而下移動的,在橫樑的移動中其實就包含了我們遊戲的主要邏輯:
     1. 產生長度隨機的橫樑
     2. 檢測擺錘與飛機的碰撞
     3. 飛過一層橫樑則得分加1
     4. 橫樑移出螢幕可視範圍,remove節點
  這裡用純css實現橫樑的移動的話會有一些邏輯無法實現,這中間必須有js來控制的。所以橫樑/擺錘的產生就放在了我們遊戲的“主執行緒”裡。
     簡單說下思路:
     有兩個常量,分別表示橫樑之間的水平距離和垂直距離,另外我們還需定義橫樑的最小長度和最大長度,在這兩個值之間產生一個隨機數作為左側橫樑的長度,然後根據水平距離來計算出右側橫樑的長度。
     至於碰撞檢測,我這裡就簡單處理了(考慮到這個擺錘在不停的擺動),直接用圓形模型來做,即兩個圓心的距離小於半徑之和則認為發生了碰撞。
     計算得分也比較簡單,只要橫樑的top值大於飛機的top值了,就認為已經越過了這一道橫樑,得分加1.
     最後,當橫樑的top值大於整個容器的高度時,說明它已經移出可視範圍,直接把節點remove掉,避免遊戲執行一段時間後,DOM節點太多造成卡頓。
     下面是主執行緒的程式碼:
bgMove : function(){
          game.generateHand();//產生橫樑
          posMark += 2;
          container.css('background-position', '0 '+posMark+'px');
         
          var hands = $('.hand_l, .hand_r');
          hands.each(function(index, element){
               var _this = $(this),
                    thisTop = parseInt(_this.css('top'));
               if(thisTop>cHeight){
                    _this.remove();
               }
               else{
                    thisTop += 2;
                    _this.css('top', thisTop+'px');
               }
               if(thisTop>player.offset().top+e1H){
                    //已經位於下方
                    if(!_this.data('pass') && index%2==0){
                         scroeC.text(++score);
                         _this.data('pass', 1);
                    }
               }
               else{
                    //碰撞檢測
                    if(game.impactCheck(player, _this.find('.t'))){
                         game.stop();
                         return false;
                    }
               }
              
          });

          timmer = requestAnimationFrame(game.bgMove);
     }
  你會發現裡面其實也有好多寫的不好的地方,例如每次重新整理一幀都會用 $('.hand_l, .hand_r')把頁面上所有的橫樑節點都取一遍,這樣掃描DOM樹挺消耗時間的。完全可以把這些節點存在一個陣列裡。產生橫樑的時候在陣列中push,需要remove的時候從陣列中刪除。
  至此,這個小遊戲的關鍵部分就都完成了。剩下就是遊戲的控制部分了,stop、restart什麼的,其實只要把控制遊戲的引數變數和class重置,cancelAnimationFrame,就ok了。
 
相容PC和手機
  這裡的相容主要是指click事件的300ms延遲,由於遊戲來說,哪怕是一點點的延遲都會不爽。所以我檢測了裝置型別,如果是移動端,就繫結touchstart事件,程式碼片段如下:
isMobile : function(){
          var sUserAgent= navigator.userAgent.toLowerCase(),
          bIsIpad= sUserAgent.match(/ipad/i) == "ipad",
          bIsIphoneOs= sUserAgent.match(/iphone os/i) == "iphone os",
          bIsMidp= sUserAgent.match(/midp/i) == "midp",
          bIsUc7= sUserAgent.match(/rv:1.2.3.4/i) == "rv:1.2.3.4",
          bIsUc= sUserAgent.match(/ucweb/i) == "ucweb",
          bIsAndroid= sUserAgent.match(/android/i) == "android",
          bIsCE= sUserAgent.match(/windows ce/i) == "windows ce",
          bIsWM= sUserAgent.match(/windows mobile/i) == "windows mobile",
          bIsWebview = sUserAgent.match(/webview/i) == "webview";
          return (bIsIpad || bIsIphoneOs || bIsMidp || bIsUc7 || bIsUc || bIsAndroid || bIsCE || bIsWM);
     }


var eventType = this.isMobile() ? 'touchstart' : 'click';
          $(document).on(eventType, function(){
               if(++direction%2==0){
                    player[0].className = 'flyl';
               }
               else{
                    player[0].className = 'flyr';
               }
          });
分享到微博
  為了讓遊戲易於傳播,在網上搜了一段分享到微博的程式碼,試了一下好用,直接貼過來:
<a id="share" href="javascript:(function(){window.open('http://v.t.sina.com.cn/share/share.php?title=網頁版SwingCopters,來,看看你有多挫&url=idoube.com/proj/SwingCopters&source=bookmark&pic=http%3A%2F%2Fidoube.com%2Fproj%2FSwingCopters%2FSwingCopters%2Fshot.jpg','_blank','width=450,height=400');})()">分享到微博</a>
  其實在手機上的話,還應該加上微信分享,但是我在手機上玩了一下這個遊戲後,頓時感覺沒必要了。因為,手機上,那個卡啊!!fps估計在20左右。配置不錯的三星尚且如此,可以想象其他安卓機會是什麼情況。
  另一個可喜的是,在iphone上玩竟然很流程!在此也不得不佩服ios對圖形渲染的處理。
  不過,如果以後再做這種動畫比較多的遊戲,我是肯定不會選擇用DOM來做了。
 
總結
  這是樓主第一次寫小遊戲,雖然最終搞出來的遊戲像模像樣也能玩,但寫的過於倉促,有些知識也沒有深究,中間踩了一些坑,整體程式碼質量也並不高。在這裡列一列吧:
  1. 有些動畫是用純css3完成,有些是寫在js裡,到底動畫該如何歸類應該細細考慮
  2. 沒有進行效能監測,我的機器配置較高,在chrome裡可以跑到接近60fps。但感覺程式碼有些地方效率並不高。在Android機上直接卡爆。
  3. 程式碼簡單,js中用了很多全域性變數。因為以前有聽人說過,簡單的程式直接用全域性變數就行,效能高,但沒有求證這種說法,不知正確與否,有高手知道請指點。
  4. 對於動畫比較多的小遊戲,用DOM來做不是一個很好的選擇,因為手機上卡,不能在微信裡分享,效果直接就大打折扣了。下次試著用canvas來寫。
  5. 整個程式碼還是操作DOM的思維,其實做遊戲應該用物件導向的風格來組織程式碼。
 
  再次附上游戲地址,歡迎體驗:http://idoube.com/proj/SwingCopters/
 
  最後推薦一個我寫css3動畫經常參考的一個文件:http://ecd.tencent.com/css3/guide.html

相關文章