三小时编写一个2048小游戏
2998
2019.08.29
发布于 未知归属地

  Booch大神说过,一个好的软件或者系统应该“为系统的接缝(seam)建模”,我一直奉为经典,想要更好地构建或者说组织好一个程序,减少开发软件的复杂程度,我们当然应该组件化,分解功能,为之后的变化提供便利,毕竟“架构的目的目标必须是减少变化的影响和成本”(--Booch)。

  我们知道,一个游戏,或者一个软件,最核心的是其玩法或者说算法。当然,我们应当实现其算法而暂时忽略游戏的外观和表象,我们把2048游戏可以抽象为一个操作矩阵的过程,2048游戏支持四种移动——向上、向下、向左、向右(其实还有随机生成方块的方法),我设计了一个数据结构集成了其中的操作,这样可以使我们的模块“自给自足”,方便测试。

game2048.js

function Game2048(n) {
    let mat = []
        ds = [
            [0, n, 1], 
            [n - 1, 0, -1],
            [0, n, 1],
            [n - 1, 0, -1]
        ],
        merge = false;
    for(let i=0; i < n; ++i) {
        let line = [];
        line.length = n;
        line.fill(0);
        mat.push(line);
    }

    function compactUp(j) {
        let p = 0;
        for(let i=0; i < n; ++i) {
            if(mat[i][j] != 0) {
                let val = mat[i][j];
                mat[i][j] = 0;
                mat[p++][j] = val;
            }
        }
    }

    this.moveUp = () => {
        merge = false;

        for(let j = 0; j < n; ++j) {
            compactUp(j);
            for(let i = 0; i < n - 1; ++i) {
                if(mat[i][j] != 0 && mat[i][j] == mat[i + 1][j]) {
                    mat[i][j] <<= 1;
                    mat[i + 1][j] = 0;
                    merge = true;
                }
            }
            compactUp(j);
        }
    }

    function compactDown(j) {
        let p = n - 1;
        for(let i=n - 1; i >= 0; --i) {
            if(mat[i][j] != 0) {
                let val = mat[i][j];
                mat[i][j] = 0;
                mat[p--][j] = val;
            }
        }
    }

    this.moveDown = () => {
        merge = false;

        for(let j = 0; j < n; ++j) {
            compactDown(j);
            for(let i = n - 1; i > 0; --i) {
                if(mat[i][j] != 0 && mat[i][j] == mat[i - 1][j]) {
                    mat[i][j] <<= 1;
                    mat[i - 1][j] = 0;
                    merge = true;
                }
            }
            compactDown(j);
        }
    }

    function compactLeft(i) {
        let p = 0;
        for(let j=0; j < n; ++j) {
            if(mat[i][j] != 0) {
                let val = mat[i][j];
                mat[i][j] = 0;
                mat[i][p++] = val;
            }
        }
    }

    this.moveLeft = () => {
        merge = false;

        for(let i=0; i < n; ++i) {
            compactLeft(i);
            for(let j=0; j < n - 1; ++j) {
                if(mat[i][j] != 0 && mat[i][j] == mat[i][j + 1]) {
                    mat[i][j] <<= 1;
                    mat[i][j + 1] = 0;
                    merge = true;
                }
            }
            compactLeft(i);
        }
    }

    function compactRight(i) {
        let p = n - 1;
        for(let j=n - 1; j >= 0; --j) {
            if(mat[i][j] != 0) {
                let val = mat[i][j];
                mat[i][j] = 0;
                mat[i][p--] = val;
            }
        }
    }

    this.moveRight = () => {
        merge = false;

        for(let i=0; i < n; ++i) {
            compactRight(i);
            for(let j=n - 1; j > 0; --j) {
                if(mat[i][j] != 0 && mat[i][j] == mat[i][j - 1]) {
                    mat[i][j] <<= 1;
                    mat[i][j - 1] = 0;
                    merge = true;
                }
            }
            compactRight(i);
        }
    }

    this.randBlock = (seed = 0.5) => {
        let sites = [];
        for(let i=0; i < n; ++i) {
            for(let j=0; j < n; ++j) {
                if(mat[i][j] == 0) {
                    sites.push([i, j]);
                }
            }
        }
        if (sites.length) {
            utils.shuffle(sites);
            mat[sites[0][0]][sites[0][1]] = (Math.random() > seed? 4: 2);
        }
    }

    this.isMerge = () => merge;

    this.toMat = () => mat;

    this.showMat = () => {
        for(let i=0; i < n; ++i) {
            let line = [];
            for(let j=0; j <n; ++j) {
                line.push(mat[i][j]);
            }
            console.log(line.join(" "));
        }
    }
}

  另外,我把里面的一些核心方法抽了出来,比如高纳德置乱方法,主要是方便我以后写其他小程序使用。
basic.js

class utils {
    /**
     * 高纳德(Knuth)置乱法
     * 在前一次未被选中的概率为 (n - i) / n
     * 本次该格被选中的概率为 1 / (n - i)
     * 相乘后,即任何元素进任意格内的概率为 1 / n
     */
    static shuffle(arr) {
        for(let i=arr.length; i > 0; --i) {
            let k = parseInt(Math.random() * i),
                t = arr[k];
            arr[k] = arr[i - 1];
            arr[i - 1] = t;
        }
    }
}

  核心算法实现后,我们就可以开始写可视化的部分了,这里我用了Canvas作为画板,为了能更方便操作Canvas绘图,我把一些方法抽了出来,形成了一个子模块。这个模块有两个核心方法,一个是画一个方块,一个是打印文字,之后我们会用到这两个函数。
my_canvas.js

function MyCanvas(canvas) {
    console.assert(canvas != null && canvas != undefined);
    let ctx = canvas.getContext("2d");

    this.clearVis = () => {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
    }

    this.rect = (R) => {
        ctx.beginPath();
        ctx.moveTo(R.x, R.y);
        ctx.lineTo(R.x, R.y + R.width);
        ctx.lineTo(R.x + R.height, R.y + R.width);
        ctx.lineTo(R.x + R.height, R.y);
        ctx.closePath();

        if(R.fillColor) {
            ctx.fillStyle = R.fillColor;
            ctx.fill();
        }
        ctx.lineWidth = Math.max(0, R.lineWidth);
        ctx.strokeStyle = R.strokeStyle? R.strokeStyle: "black";
        ctx.stroke();
    }

    this.string = (T) => {
        ctx.font = T.font;
        ctx.fillStyle = T.fillColor;
        ctx.textAlign = T.align? T.align: "center";
        ctx.textBaseline = T.baseline? T.baseline: "middle";
        ctx.fillText(T.content, T.x, T.y);
    }
}

  好了,核心算法和可视化的模块都已经实现了,我们还需要把两个模块“胶接”在一起,也就是游戏的控制,比如键盘事件监听、游戏配置、2048里方块的颜色大小偏移、按键方向提示、绘制文字设置等等,这些代码并不是核心,但是比较影响玩家的体验,所以我在这个部分写一下,这个部分代码虽然很多,但大多只是描述以及调用了上面的核心模块实现。
index.js

(function() {
    const N = 4;
    const SIZE = 50;
    const MID = 5;
    const LEFT = 500;
    const TOP = 200;
    const TAR_SCORE = Math.pow(2, 11);
    const TWO_RATE = 0.8;
    let dirs = [[[1,1,1],[0,1,0],[0,1,0]],[[0,1,0],[0,1,0],[1,1,1]],[[1,0,0],[1,1,1],[1,0,0]],[[0,0,1],[1,1,1],[0,0,1]]];
    let can = new MyCanvas(document.getElementById("myCanvas")),
        game = new Game2048(N),
        lastCode = null,
        colors = ["#AAA", "#82D900", "#2894FF", "#004B97", "#4A4AFF", "#613030", "#EAC100", "#FF9224", "#BB5E00", "#B766AD", "#FF5809", "#7E3D76"],
        dict = {},
        over = false;
    dict[0] = colors[0];
    for(let i=1; i < colors.length; ++i) dict[Math.pow(2, i)] = colors[i];

    // update game canvas
    function show() {
        let mat = game.toMat();
        const my_font = "15px bold 微软雅黑";
        const box_size = SIZE + MID;
        const txt_center = (box_size >> 1);
        for(let i=0; i < N; ++i) {
            for(let j=0; j < N; ++j) {
                if(mat[i][j] == TAR_SCORE) over = true;
                can.rect({
                    x: LEFT + j * box_size,
                    y: TOP + i * box_size,
                    width: SIZE,
                    height: SIZE,
                    fillColor: dict[mat[i][j]],
                    strokeStyle: "#EFEFEF"
                });
                if(mat[i][j] > 0) {
                    can.string({
                        x: LEFT + txt_center + j * box_size,
                        y: TOP + txt_center + i * box_size,
                        content: mat[i][j],
                        font: my_font,
                        fillColor: "#FFF"
                    });
                }
            }
        }
    }

    // show current arrow
    function showArrow(i) {
        const size = 10;
        const top = 200;
        const left = 450;
        let dir = dirs[i];
        for(let i=0; i < 3; ++i) {
            for(let j=0; j < 3; ++j) {
                can.rect({
                    x: left + j * size,
                    y: top + i * size,
                    width: size,
                    height: size,
                    fillColor: dir[i][j]? "#D94600": "#EFEFEF",
                    strokeStyle: "#FFF"
                });
            }
        }
    }

    // game converge
    function gameConverge(code) {
        switch(code) {
            case "KeyW":
            case "ArrowUp":
                game.moveUp();
                showArrow(0);
                break;
            case "ArrowDown":
            case "KeyS":
                game.moveDown();
                showArrow(1);
                break;
            case "ArrowLeft":
            case "KeyA":
                game.moveLeft();
                showArrow(2);
                break;
            case "ArrowRight":
            case "KeyD":
                game.moveRight();
                showArrow(3);
                break;
            default:
                return false;
        }
        return true;
    }
    
    document.addEventListener("keydown", (event) => {
        if(!over && gameConverge(event.code)) {
            if (lastCode != event.code) game.randBlock(TWO_RATE);
            lastCode = event.code;
            show();
            if(over) {
                setTimeout(() => {
                    alert("恭喜你,游戏通关了!");
                    window.location.reload();
                }, 100);
            }
        }
    });

    // init game
    for(let i=0; i < N; ++i) game.randBlock();
    show();
})()

  最后,我们用一个HTML把这些都组合起来。
game2048.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>2048</title>
    <script type="text/javascript" src="./basic.js"></script>
    <script type="text/javascript" src="./game2048.js"></script>
    <script type="text/javascript" src="./my_canvas.js"></script>
</head>
<body style="overflow: hidden;">
    <canvas id="myCanvas" width="1000" height="1000">
        can't support canvas
    </canvas>
    <script type="text/javascript" src="./index.js"></script>
</body>
</html>

  见证奇迹的时刻到了,打开HTML我们就可以愉快玩耍了。
image.png

  你学会了吗?

评论 (1)