Vue+Webpack 實戰 實現小遊戲:2048

前言

最近學習Vue和webpack,恰好搞個小遊戲練練手。
2048遊戲規則:css

每次能夠選擇上下左右其中一個方向去滑動,每滑動一次,全部的數字方塊都會往滑動的方向靠攏外,系統也會在空白的地方亂數出現一個數字方塊,相同數字的方塊在靠攏、相撞時會相加。不斷的疊加最終拼湊出2048這個數字就算成功。html

固然有些細微的合併規則,好比:
當向左滑動時,某列2 2 2 2 合併成 4 4 0 0 而非 8 0 0 0
也就是說,同列的某個數字最多隻被合併一次。
在線挑戰一把?
(瞎折騰一陣後發現移動端沒法正常顯示了,趕忙調試去)
http://www.ccc5.cc/2048/
移動端掃下面的二維碼便可(微信會轉碼,請點擊掃描後底部的‘訪問原網頁’)
圖片描述vue

圖片描述

思路

  • 4x4的方格,用一個16個成員的數組表示。當某個方格沒有數字的時候用''表示;node

  • 建立Vue的模板,綁定數據,處理數據與相關class的關係;webpack

  • 把數組看成一個4x4的矩陣,專一數據方面的處理,Dom方面的就交給vue更新ios

模板

<template>
    <div id="app">
        <ul>
            <li class='box' 
                v-for="num in nums"
                v-text="num"
                v-getclass="num"
                v-getposition="$index"
                track-by="$index"></li>
        </ul>
        <button @click="reset()">重置</button>
    </div>    
</template>

其中getclassgetposition爲自定義指令:git

  • getclass根據當前框數字設置不一樣的classgithub

  • getposition根據當前框的索引位置,設置css樣式的topleftweb

關鍵實現

初始化數據

初始化一個長度爲16的數組,而後隨機選兩個地方填入2或者4。算法

<script>
export default {
    data () {
        return {
            nums : []   //記錄16個框格的數字
        }
    },
    ready: function () {
        localStorage['save'] ? this.nums = JSON.parse(localStorage['save'])
                             : this.reset();
    },
    methods:{
        /*在一個隨機的空白位添加2或4 機率9:1*/
        randomAdd(){
            let arr = this.shuffle(this.blankIndex());
            //延時100毫秒添加
            setTimeout(_=>{
               this.nums.$set(arr.pop(),Math.random()>0.9 ? 4 : 2); 
            },100);
        },
        /*獲取當前空白框索引組成的數組*/
        blankIndex(){
            let arr = [];
            this.nums.forEach((i,j)=>{
                i==='' && arr.push(j);
            });
            return arr;
        },
        /*打亂數組*/
        shuffle(arr){
            let l = arr.length,j;
            while(l--){
                j = parseInt(Math.random()*l);
                [arr[l],arr[j]] = [arr[j],arr[l]]
            }
            return arr;
        },
        //保存遊戲進度
        save(){
           localStorage['save'] = JSON.stringify(this.nums); 
        },
        //重置遊戲
        reset(){
            this.nums = Array(16).fill('');
            let i =0;
            while(i++<2){ //隨機添加2個
               this.randomAdd(); 
            }
        }
    }
}
</script>

這裏有必要說明下,在segmentfault看到不少人洗牌算法習慣這麼寫:

var arr = arr.sort(_=> {
    return Math.random() - 0.5
});

可是通過不少人的測試,這樣洗牌實際上是不太亂的,具體參考
數組的徹底隨機排列:https://www.h5jun.com/post/ar...
如何測試洗牌程序:http://coolshell.cn/articles/...

繼續回到主題,數據初始化完成以後,添加兩個自定義指令,功能前面已經講過了,實現也很簡單,
方格里面數字不一樣,對應的class不同,我這裏定義的規則是
數字2對應.s2
數字4對應.s4
...

<script>
directives:{
    getclass(value) {
        let classes = this.el.classList;
        /*
        //移動端貌似對classList的forEach支持不完善
        //我在ios上調試了好久才找到是這裏的緣由致使以前移動端無法正常運行
        classes.forEach(_=>{
            if(/^s\w+$/.test(_))classes.remove(_);
        });*/
        Array.prototype.forEach.call(classes,_=>{
            if(/^s\w+$/.test(_))classes.remove(_);
        });
        value ? classes.add('s' + value) 
              : classes.add('empty');
    },
    getposition(index){
        this.el.style.left = index%4*25 + '%';
        this.el.style.top = Math.floor(index/4)*25 + '%';
    }
}
</script>

事件處理

監聽4個方向鍵和移動端的滑動事件,在ready環節處理

ready: function () {
        document.addEventListener('keyup', this.keyDown);
        document.querySelector('#app ul').addEventListener('touchstart', this.touchStart);
        document.querySelector('#app ul').addEventListener('touchend', this.touchEnd);
        //document上獲取touchmove事件 若是是由.box觸發的 則禁止屏幕滾動
        document.addEventListener('touchmove', e=>{
            e.target.classList.contains('box') && e.preventDefault();
        });
    },
    methods:{
        touchStart(e){
            //在start中記錄開始觸摸的點
            this.start['x'] = e.changedTouches[0].pageX;
            this.start['y'] = e.changedTouches[0].pageY;
        },
        touchEnd(e){
            //handle...
        },
        /*
        *方向鍵 事件處理
        */
        keyDown(e){
            //handle...
        }
    }

咱們將nums這個數組想象成一個4x4的方陣。

2 2 2 2
        x x x x
        x x x x
        x x x x

向左合併的時候,以第一列來講,從左至右:

  • inxde=0位置的2在合併時是不須要動的,index=1位置的2和index=0位置的2數值相同,合併成4,放在index=0的位置,第一列變成4 '' 2 2

  • index=2位置的2,向左挪到index=1空出的位置,變成4 2 '' 2,同時,index=3位置的2一直向左運動,直到碰上index=1處的2,兩者在這輪都沒有過合併,因此這裏能夠合併爲4 4 '' ''

從這裏咱們能夠看出,向左運動時,最左的位置,也就是index%4 === 0的位置,是無需挪動的,其餘位置,如有數字,其左側有空位的話則向左挪動一位,其左側有個與其相同的數字時,且兩者在此輪中沒有合併過,則兩者合併,空出當前位:

  • 2 2 2 2 => 4 4 '' ''

  • 4 2 2 2 => 4 4 2 ''

  • '' 4 2 2=> 4 4 '' ''

  • 2 2 4 2 => 4 4 2 ''

  • ...

若是打算將向上,向右,向下都這麼處理一遍並不是不能夠,但比較容易出錯。咱們既然都將nums想象成了一個4x4的方陣,那麼何不將該方陣旋轉一下呢。
向下運動時,咱們先將nums利用算法將想象中的方陣順時針旋轉一次,而後能夠用向左運動處理的方法合併、移動方格,完畢後再順時針旋轉3次,或者逆時針旋轉1次還原便可。

旋轉算法:

<script>
methods{
//...
    //arr 須要旋轉的數組
    //n  順時針選擇n次
    T(arr,n){
        n=n%4;
        if(n===0)return arr;
        var l = arr.length,d = Math.sqrt(l),tmp = [];
        for(var i=0;i<d;i+=1)
            for(var j=0;j<d;j+=1)
                tmp[d-i-1+j*d] = arr[i*d+j];
        if(n>1)tmp=this.T(tmp,n-1);
        return tmp;
    }
//...

}
</script>

圖片描述
有了這個函數,咱們就只要寫向左運動的處理函數了

<script>
move(){
    let hasMove = false, //一次操做有移動方塊時才添加方塊
        /*
        *記錄已經合併過一次的位置 避免重複合併
        *如 2 2 4 4 在一次合併後應爲 4 8 0 0  而非8 4 0 0 
        */
        hasCombin = {};
    tmp.forEach((j,k)=>{
        while(k%4 && j!==''){
            if(tmp[k-1] === ''){ //當前位置的前一位置爲空,交換倆位置
                tmp[k-1] = j;
                tmp[k] = '';
                hasMove = true;
                if(hasCombin[k]){//當前位有過合併,隨着挪動,也要標記到前面去
                    hasCombin[k-1] = true;
                    hasCombin[k] = false;
                }
            }else if(tmp[k-1] === j && !hasCombin[k] && !hasCombin[k-1]){
                //當前位置與前一位置數字相同,合併到前一位置,而後清空當前位置
                j *= 2;
                tmp[k-1] = j;
                tmp[k] = '';
                hasMove = true;
                hasCombin[k-1] = true;  //記錄合併位置
            }else{
                break;
            }
            k--;
        } 
    });
}
</script>

詳細的方向轉換及處理過程

<script>
    methods:{
        /*把數組arr當成矩陣,順時針轉置n次*/
        T(arr,n){
            n=n%4;
            if(n===0)return arr;
            var l = arr.length,d = Math.sqrt(l),tmp = [];
            for(var i=0;i<d;i+=1)
                for(var j=0;j<d;j+=1)
                    tmp[d-i-1+j*d] = arr[i*d+j];
            if(n>1)tmp=this.T(tmp,n-1);
            return tmp;
        },
        touchStart(e){
            this.start['x'] = e.changedTouches[0].pageX;
            this.start['y'] = e.changedTouches[0].pageY;
        },
        touchEnd(e){
            let curPoint = e.changedTouches[0],
                x = curPoint.pageX - this.start.x,
                y = curPoint.pageY - this.start.y,
                xx = Math.abs(x),
                yy = Math.abs(y),
                i = 0;
            //移動範圍過小 不處理
            if(xx < 50 && yy < 50)return;    
            if( xx >= yy){ //橫向滑動
                i = x < 0 ? 0 : 2;
            }else{//縱向滑動
                i = y < 0 ? 3 : 1;
            }
            this.handle(i);
        },
        /*
        *方向鍵 事件處理
        *把上、右、下方向經過旋轉 變成左向操做
        */
        keyDown(e){
            //左上右下 分別轉置0 3 2 1 次
            const map = {37:0,38:3,39:2,40:1};
            if(!(e.keyCode in map))return;
            this.handle(map[e.keyCode]);
        },
        handle(i){
            this.move(i);
            this.save();
        },
        /*移動滑塊 i:轉置次數 */
        move(i){
            let tmp = this.T(this.nums,i),//把任意方向鍵轉置,當成向左移動
                hasMove = false, //一次操做有移動方塊時才添加方塊
                /*
                *記錄已經合併過一次的位置 避免重複合併
                *如 2 2 4 4 在一次合併後應爲 4 8 0 0  而非8 4 0 0 
                */
                hasCombin = {};
            tmp.forEach((j,k)=>{
                while(k%4 && j!==''){
                    if(tmp[k-1] === ''){ //當前位置的前一位置爲空,交換倆位置
                        tmp[k-1] = j;
                        tmp[k] = '';
                        hasMove = true;
                        if(hasCombin[k]){
                            hasCombin[k-1] = true;
                            hasCombin[k] = false;
                        }
                    }else if(tmp[k-1] === j && !hasCombin[k] && !hasCombin[k-1]){
                        //當前位置與前一位置數字相同,合併到前一位置,而後清空當前位置
                        j *= 2;
                        tmp[k-1] = j;
                        tmp[k] = '';
                        hasMove = true;
                        hasCombin[k-1] = true;  //記錄合併位置
                    }else{
                        break;
                    }
                    k--;
                } 
            });
            this.nums = this.T(tmp,4-i);//轉置回去,把數據還給this.nums
            hasMove && this.randomAdd();//有數字挪動才添加新數字
        }
    }
</script>

當全部的16個方格都已經被填滿,且橫向與縱向都沒法合併時,遊戲結束

isPass(){
            let isOver=true,hasPass=false,tmp = this.T(this.nums,1);
            this.nums.forEach((i,j)=>{
                if(this.nums[j-4] == i || this.nums[j+4] == i || tmp[j-4] == tmp[j] || tmp[j+4] == tmp[j]){
                    isOver = false;
                }
                if(i==2048){
                    hasPass = true;
                }
            });
            if(!this.blankIndex().length){
                isOver && alert('遊戲結束!');
            };
        }

。。。。。。。。
如何才能PASS?你有本事能夠玩到很恐怖的數字。具體能玩到多少須要用數學證實吧,已有知乎大神證實,估計在3884450左右。傳送門https://www.zhihu.com/questio...

代碼

奉上所有代碼app.vue

<template>
    <div id="app">
        <ul>
            <li class='box' 
                v-for="num in nums"
                v-text="num"
                v-getclass="num"
                v-getposition="$index"
                track-by="$index"></li>
        </ul>
        <button @click="reset()">重置</button>
    </div>    
</template>

<script>
export default {
    data () {
        return {
            start : {},  //記錄移動端觸摸起始點
            nums : []   //記錄15個框格的數字
        }
    },
    ready: function () {
        document.addEventListener('keyup', this.keyDown);
        document.querySelector('#app ul').addEventListener('touchstart', this.touchStart);
        document.querySelector('#app ul').addEventListener('touchend', this.touchEnd);
        //document上獲取touchmove事件 若是是由.box觸發的 則禁止屏幕滾動
        document.addEventListener('touchmove', e=>{
            e.target.classList.contains('box') && e.preventDefault();
        });
        localStorage['save'] ? this.nums = JSON.parse(localStorage['save'])
                             : this.reset();
    },
    directives:{
        getclass(value) {
            let classes = this.el.classList;
            classes.forEach(_=>{
                if(/^s\w+$/.test(_))classes.remove(_);
            });
            value ? classes.add('s' + value) 
                  : classes.add('empty');
        },
        getposition(index){
            this.el.style.left = index%4*25 + '%';
            this.el.style.top = Math.floor(index/4)*25 + '%';
        }
    },
    methods:{
        /*在一個隨機的空白位添加2或4 機率9:1*/
        randomAdd(){
            let arr = this.shuffle(this.blankIndex());
            //延時100毫秒添加
            setTimeout(_=>{
               this.nums.$set(arr.pop(),Math.random()>0.9 ? 4 : 2); 
            },100);
        },
        /*獲取當前空白隔索引組成的數組*/
        blankIndex(){
            let arr = [];
            this.nums.forEach(function(i,j){
                i==='' && arr.push(j);
            });
            return arr;
        },
        /*打亂數組*/
        shuffle(arr){
            let l = arr.length,j;
            while(l--){
                j = parseInt(Math.random()*l);
                [arr[l],arr[j]] = [arr[j],arr[l]]
            }
            return arr;
        },
        /*把數組arr當成矩陣,轉置n次*/
        /*
            [1,2,   1次轉置變爲     [3,1,
            3,4]                     4,2]  
        */
        T(arr,n){
            n=n%4;
            if(n===0)return arr;
            var l = arr.length,d = Math.sqrt(l),tmp = [];
            for(var i=0;i<d;i+=1)
                for(var j=0;j<d;j+=1)
                    tmp[d-i-1+j*d] = arr[i*d+j];
            if(n>1)tmp=this.T(tmp,n-1);
            return tmp;
        },
        touchStart(e){
            this.start['x'] = e.changedTouches[0].pageX;
            this.start['y'] = e.changedTouches[0].pageY;
        },
        touchEnd(e){
            let curPoint = e.changedTouches[0],
                x = curPoint.pageX - this.start.x,
                y = curPoint.pageY - this.start.y,
                xx = Math.abs(x),
                yy = Math.abs(y),
                i = 0;
            //移動範圍過小 不處理
            if(xx < 50 && yy < 50)return;    
            if( xx >= yy){ //橫向滑動
                i = x < 0 ? 0 : 2;
            }else{//縱向滑動
                i = y < 0 ? 3 : 1;
            }
            this.handle(i);
        },
        /*
        *方向鍵 事件處理
        *把上、右、下方向經過旋轉 變成左向操做
        */
        keyDown(e){
            //左上右下 分別轉置0 3 2 1 次
            const map = {37:0,38:3,39:2,40:1};
            if(!(e.keyCode in map))return;
            this.handle(map[e.keyCode]);
        },
        handle(i){
            this.move(i);
            this.save();
            this.isPass();//判斷是否過關
        },
        /*移動滑塊 i:轉置次數 */
        move(i){
            let tmp = this.T(this.nums,i),//把任意方向鍵轉置,當成向左移動
                hasMove = false, //一次操做有移動方塊時才添加方塊
                /*
                *記錄已經合併過一次的位置 避免重複合併
                *如 2 2 4 4 在一次合併後應爲 4 8 0 0  而非8 4 0 0 
                */
                hasCombin = {};
            tmp.forEach((j,k)=>{
                while(k%4 && j!==''){
                    if(tmp[k-1] === ''){ //當前位置的前一位置爲空,交換倆位置
                        tmp[k-1] = j;
                        tmp[k] = '';
                        hasMove = true;
                        if(hasCombin[k]){
                            hasCombin[k-1] = true;
                            hasCombin[k] = false;
                        }
                    }else if(tmp[k-1] === j && !hasCombin[k] && !hasCombin[k-1]){
                        //當前位置與前一位置數字相同,合併到前一位置,而後清空當前位置
                        j *= 2;
                        tmp[k-1] = j;
                        tmp[k] = '';
                        hasMove = true;
                        hasCombin[k-1] = true;  //記錄合併位置
                    }else{
                        break;
                    }
                    k--;
                } 
            });
            this.nums = this.T(tmp,4-i);//轉置回去,把數據還給this.nums
            hasMove && this.randomAdd();
        },
        save(){
           localStorage['save'] = JSON.stringify(this.nums); 
        },
        //重置遊戲
        reset(){
            this.nums = Array(16).fill('');

            let i =0;
            while(i++<2){ //隨機添加2個
               this.randomAdd(); 
            }
        },
        isPass(){
            let isOver=true,hasPass=false,tmp = this.T(this.nums,1);
            this.nums.forEach((i,j)=>{
                if(this.nums[j-4] == i || this.nums[j+4] == i || tmp[j-4] == tmp[j] || tmp[j+4] == tmp[j]){
                    isOver = false;
                }
                if(i==2048){
                    hasPass = true;
                }
            });
            if(!this.blankIndex().length){
                isOver && alert('遊戲結束!');
            };
        }
    }
}
</script>

<style>
    @import url(http://fonts.useso.com/css?family=Inknut+Antiqua);
    @import url(./main.css);
</style>

index.html

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta content="telephone=no,email=no" name="format-detection">
    <meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>2048 Game</title>
    <style>
        html,body{
            width:100%;
        }
        body{
            display:flex;
            justify-content: center;
            align-items: center;
            overflow:hidden;
        }
    </style>
</head>
<body>
    <app></app>
    <script src="bundle.js"></script>
</body>
</html>

main.js

import Vue from 'vue'
import App from './app.vue'


new Vue({
  el: 'body',
  components:{App}
});

webpack配置

const webpack = require('webpack');

module.exports = {
  entry: {
      app:["./app/main.js",'webpack-hot-middleware/client','webpack/hot/dev-server']
  },
  output: {
    path: __dirname + "/public",//打包後的文件存放的地方
    filename: "bundle.js",//打包後輸出文件的文件名
    publicPath:'http://localhost:8080/'
  },
  module: {
    loaders: [
      {test: /\.css$/, loader: 'style!css'},
      {test: /\.vue$/, loader: 'vue'},
      {test: /\.js$/,exclude:/node_modules|vue\/dist|vue-router\/|vue-loader\/|vue-hot-reload-api\// ,loader: 'babel'}
    ]
  },
  vue:{
    loaders:{
      js:'babel'
    }
  },
  babel: {
    presets: ['es2015','stage-0'],
    plugins: ['transform-runtime']
  },
  plugins:[
    new webpack.HotModuleReplacementPlugin()
  ],
  devServer: {
    contentBase: "./public",//本地服務器所加載的頁面所在的目錄
    colors: true,//終端中輸出結果爲彩色
    hot: true,
    inline: true//實時刷新
  },
  resolve: {
    extensions: ['', '.js', '.vue']
  }    
}

css就不貼了,Github上託管了:https://github.com/Elity/2048...

好久沒寫這麼長的文章了,累死。花了整整倆小時!!!

相關文章
相關標籤/搜索