消除游戏

用 phaser 制作消除游戏

最近我使用 phaser 游戏框架做了一个简单的消除游戏。

基本设置

我使用了 electron 而不是 http 服务器来做本地调试,
因为 electron 自带 require,可以使我的代码更模块化,
而且我还可以使用 ES6。

所以我的游戏入口设置是这样的:

const game = new Phaser.Game(options.width, options.height);

game.state.add('InGame', InGame);
game.state.start('InGame');

我的游戏只有一个 in game 游戏状态,这里会有所有的游戏逻辑。

游戏逻辑

首先是构造器,这里面会有我们要用到的状态,比较重要的是以下几个:

class InGame {
    constructor() {
        // 是否允许用户点击
        // 如果有消除或填充的动画,则不允许用户点击
        this.canPick = true;
        // 二维数组,保存画面上所有可以消除的方块
        this.tilesArray = [];
        // 方块回收池
        // 当用户消除了一些方块时,那些方块不会被删除,而是暂时回到回收池
        this.tilePool = [];
    }
}

有了这些状态之后,我们就可以预加载图片并且在画面上显示所有的方块了。
预加载的代码我先省略掉。

主要看 InGame 游戏状态的create方法

class InGame {
    preload() {
        // 预加载代码
        // 例如:this.game.load.image('tile', './tile.png')
    }
    create() {
        // 这里一般会有缩放代码来适配各种屏幕
        this.createLv();
    }
    createLv() {
        for (let i = 0; i < opts.rows; i++) {
            this.tilesArray[i] = [];
            for (let j = 0; j < opts.cols; j++) {
                this.addTile(i, j);
            }
        }       
    }
    addTile(row, col) {
        const tile = this.makeTile(row, col);

        this.tilesArray[row][col] = {
            tileSprite: tile,
            isEmpty: false,
            coordinate: new Point(row, col),
            tint: tile.tint
        };
    }

    makeTile(row, col) {
        const left = (col + 0.5) * opts.tileSize;
        const top = (row + 0.5) * opts.tileSize;

        const theTile = this.game.add.sprite(left, top, 'tiles');

        theTile.anchor.set(0.5);
        theTile.width = opts.tileSize;
        theTile.height = opts.tileSize;

        const colorIndex = this.game.rnd.integerInRange(0, opts.colors.length - 1);

        theTile.tint = opts.colors[colorIndex];

        return theTile;
    }
}

这个 opts 定量里面有设定游戏有多少行和列,
所以 createLv 方法先遍历行,再遍历列,然后对对应的行和列添加方块
makeTile 方法会生成对应的方块并添加到游戏当中
注意: * 方块的中心在方块的中间,所以计算位置时会有 + 0.5 * 每个方块有一个随机的颜色,用的是 Phaser 的 rnd.integerInRange 方法 最后在 addTile 方法里面,我把生成的 sprite ,相应的位置,还有颜色添加到了 tilesArray 里面

这时打开游戏,已经能看到所有的方块已经显示出来了。

但是,现在点击这些方块还没有任何效果。

所以,我们需要对用户的点击操作进行处理:

class InGame {
    createLv() {
        // 遍历添加方块的代码省略
        this.game.input.onDown.add(this.pickTile, this);
    }

    pickTile(e) {
        if (this.canPick) {
            const posX = e.x;
            const posY = e.y;

            // 这里的 e 是事件对象
            // 事件触发的 x 决定触发在哪个列
            // 事件触发的 y 决定触发在哪个行
            // 一定要小心并弄清它们的关系
            const pickedRow = Math.floor(posY / opts.tileSize);
            const pickedCol = Math.floor(posX / opts.tileSize);

            // isValidTile 函数检查用户的点击是否在合法范围内,略。
            if (this.isValidTile(pickedRow, pickedCol)) {
                const pickedTile = this.tilesArray[pickedRow][pickedCol];
                // 真正的消除逻辑
                this.tryPick(pickedTile);
            }
        }
    }

    tryPick(theTile) {
        const fill = [];
        this.floodFill(theTile.coordinate, theTile.tint, fill);

        if (fill.length > 2) {
            this.destroyTiles(fill);
        }
    }
}

当用户点击鼠标左键时,会触发回调 pickTile 函数,具体逻辑在注释中有。

如果用户的点击合法,就调用 tryPick 函数。
这个函数初始化了一个 fill 变量,保存这次点击会影响到的方块。
之后有一个 floodFill 的方法,它会递归查找受影响的方块。

最后检查受影响的方块的数量,如果是3个或更多,就可以消除它们,即三消。

那就来看看 floodFill 是如何递归的:

class InGame {
    floodFill(point, color, fillArr) {
        const {x, y} = point;
        if (!this.isValidTile(x, y)) {
            return;
        }
        if (this.tilesArray[x][y].isEmpty) {
            return;
        }
        if (this.pointInArr(fillArr, point)) {
            return;
        }
        if (this.tilesArray[x][y].tint === color) {
            fillArr.push(point);
            this.floodFill(new Point(x + 1, y), color, fillArr);
            this.floodFill(new Point(x - 1, y), color, fillArr);
            this.floodFill(new Point(x, y + 1), color, fillArr);
            this.floodFill(new Point(x, y - 1), color, fillArr);
        }
    }

    pointInArr(arr, point) {
        return arr.some(myPoint => myPoint.equals(point));
    }
}

这个函数有3个参数,第一个是初始点,第二个是初始颜色,第三个是受影响的点的数组。

之后会做一些条件判定,包括: - 递归的点是否合法,比如当前的点是(0, 0), 之后会向(-1, 0) 等非法点递归 - 检查当前的格子是否为空 - 检查当前的点是否已经被加入到了数组中,这里用的是数组的 Array.some 方法 - 当前颜色是否和初始点的颜色一样 如果符合条件,会把当前的点加入到数组中,并向4个方向递归,这也是 windows 中“画图”应用中的“填充”功能的递归方法。

当我们知道所有同样颜色的方块之后,就可以消除它们了。

在 Phaser 官方的教程中,如果你碰到了一个星星,你就使用 kill 方法移除那个星星的 sprite
但那不一定是最好的选择,来看我是如果移除方块的:

class InGame {
    destroyTiles(tileList) {
        // 禁止用户在动画中点击方块
        this.canPick = false;

        tileList.forEach(point => {
            // 按位置查找需要消除的方块
            const tile = this.tilesArray[point.x][point.y];
            // 不需要真的移除这些方块,而是让他们的透明度变为0
            // 这里是用动画让透明度逐渐变化
            const tween = this.game.add.tween(tile.tileSprite).to({
                alpha: 0
            }, 300, Phaser.Easing.Linear.None, true);
            // 被“移除”的方块回到池中
            this.tilePool.push(tile.tileSprite);
            tween.onComplete.add(this.fillHoles, this);
            // 当前的方块被标记为空
            tile.isEmpty = true;
        });
    }
}

当动画完成后,执行 fillHoles 回调函数,并用重复利用池中的方块。

代码我就不在这里展示了,因为都是一些面向过程的代码,而且比较长。

项目在这里: same

-