面试官:上机实现一个扫雷

1,624 阅读6分钟

最近想找些项目练练手,发现去复刻一些小游戏还挺有意思的,于是就做了一个网页版的扫雷。

点击这里看看最终的效果。

创建应用

该项目使用的是 monorepo 的形式来存放代码。在 Angular 中,构建 monorepo 方法如下:

ng new simple-game --createApplication=false 
ng generate application mine-sweeper

在这里,因为该项目以后还会包含其他各种其他的应用,所以个人觉得使用 monorepo 构建项目是比较正确的选择。如果不想使用 monorepo,使用以下命令创建应用:

ng new mine-sweeper

流程图

首先,我们先来看看扫雷的基本流程。

流程图

数据结构抽象

通过观察流程图,可以得到扫雷基本上有这么几种状态:

  • 开始
  • 进行游戏
  • 胜利
  • 失败

方块的状态如下:

  • 它有雷无雷,取决于它的初始设置;
  • 如果没有雷,那么它需要展示附近地雷的数量;
  • 是否已经被打开;

我们可以先定义好这些状态,之后根据不同的状态,执行不同的逻辑,同时反馈给组件。

// model.ts

export enum GameState {
    BEGINNING = 0x00,
    PLAYING = 0x01,
    WIN = 0x02,
    LOST = 0x03,
}

export interface IMineBlock {
    // 当前块是否是的内部是地雷
    readonly isMine: boolean;
    // 附近地雷块的数量
    readonly nearestMinesCount: number;
    // 是否已经被点开
    readonly isFound: boolean;
}

编写逻辑

为了使得扫雷的逻辑不跟组件耦合,我们需要新增一个 service。

ng generate service mine-sweeper

现在开始逻辑编写。首先,要存储游戏状态、地雷块、地雷块边长(目前设计的扫雷是正方形)、雷的数量。

export class MineSweeperService {

    private readonly _mineBlocks = new BehaviorSubject<IMineBlock[]>([]);

    private readonly _side = new BehaviorSubject(10);

    private readonly _state = new BehaviorSubject<GameState>(GameState.BEGINNING);

    private readonly _mineCount = new BehaviorSubject<number>(10);

    readonly side$ = this._side.asObservable();

    readonly mineBlock$ = this._mineBlocks.asObservable();

    readonly state$ = this._state.asObservable();

    readonly mineCount$ = this._mineCount.asObservable();

    get side() { return this._side.value; }

    set side(value: number) { this._side.next(value); }

    get mineBlocks() { return this._mineBlocks.value; }

    get state() { return this._state.value; }

    get mineCount() { return this._mineCount.value; }

    //...
}

得益于 Rxjs,通过使用 BehaviorSubject 使得我们可以很方便的将这些状态变量设计成响应式的。 BehaviorSubject 主要功能是提供了一个响应式的对象,使得逻辑服务可以通过这个对象对数据进行变更,并且,组件也可以通过这些对象来监听数据变化。

通过上面的准备工作,我们可以开始编写逻辑函数 startdoNextstart的作用是给状态机重新设置状态;而doNext的作用是根据玩家点击的方块的索引对游戏进行状态转移。

export class MineSweeperService {
    // ...
    
    start() {
        this._mineBlocks.next(this.createMineBlocks(this.side));
        this._state.next(GameState.BEGINNING);
    }

    doNext(index: number): boolean {
        switch (this.state) {
            case GameState.LOST:
            case GameState.WIN:
                return false;

            case GameState.BEGINNING:
                this.prepare(index);
                this._state.next(GameState.PLAYING);
                break;

            case GameState.PLAYING:
                if (this.testIsMine(index)) {
                    this._state.next(GameState.LOST);
                }
                break;

            default:
                break;
        }
        if (this.vitoryVerify()) {
            this._state.next(GameState.WIN);
        }

        return true;
    }
    
    // ...
}

上面的代码中包含了 prepare, testIsMine, victoryVerify 这三个函数,他们的作用都是进行一些逻辑运算。

我们先看prepare,因为他是最先运行的。这个函数的主要逻辑是通过随机数生成地雷,并且保证使得用户第一次点击地雷块的时候,不会出现雷。配合着注释,我们一行一行的分析它是怎么运行的。

export class MineSweeperService {
    private prepare(index: number) {
        const blocks = [...this._mineBlocks.value];
        // 判断index是否越界了
        if (!blocks[index]) {
            throw Error('Out of index.');
        }
        // 将索引位置的块设置为已经打开的状态。
        blocks[index] = { isMine: false, isFound: true, nearestMinesCount: 0 };

        // 生成随机数数组,其中的随机数不包含 index。
        const numbers = this.generateRandomNumbers(this.mineCount, this.mineBlocks.length, index);
        // 通过随机数数组,设置指定的块为雷。
        for (const num of numbers) {
            blocks[num] = { isMine: true, isFound: false, nearestMinesCount: 0 };
        }

        // 使用横纵坐标遍历所有的地雷块
        // 这样做使得我们可以直接通过对坐标的增减来检测当前块附近雷的数量。
        const side = this.side;
        for (let i = 0; i < side; i++) {
            for (let j = 0; j < side; j++) {
                const index = transform(i, j);
                const block = blocks[index];
                // 如果当前块是雷,那么不进行检测
                if (block.isMine) {
                    continue;
                }

                // 进行地雷块的附近的雷的数量检测,形如这样
                //  x 1 o
                //  1 1 o
                //  o o o
                //
                let nearestMinesCount = 0;
                for (let x = -1; x <= 1; x++) {
                    for (let y = -1; y <= 1; y++) {
                        nearestMinesCount += this.getMineCount(blocks[transform(i + x, j + y)]);
                    }
                }
                // 对附近的地雷的数量进行更新
                blocks[index] = { ...block, nearestMinesCount };
            }
        }

        // 如果点击的位置附近的地雷数量是 0,则需要遍历附近所有的块,直到所有打开的块附近的地雷数量不为零。
        if (blocks[index].nearestMinesCount === 0) {
            this.cleanZeroCountBlock(blocks, index, this.transformToIndex(this.side));
        }

        // 触发更新
        this._mineBlocks.next(blocks);
    }
}

再来看testIsMine,其作用是返回一个布尔值,这个布尔值表示用户点击的块是否为地雷。

private testIsMine(index: number): boolean {
    const blocks = [...this._mineBlocks.value];
    if (!blocks[index]) {
        throw Error('Out of index.');
    }

    // 当前块为设打开状态
    const theBlock = { ...blocks[index], isFound: true };
    blocks[index] = theBlock;

    // 如果当前块是地雷,则找出所有是地雷的地雷块,将其状态设置为打开状态。
    // 或者如果点击的位置附近的地雷数量是 0,则需要遍历附近所有的块,直到所有打开的块附近的地雷数量不为零。
    if (theBlock.isMine) {
        for (let i = 0; i < blocks.length; i++) {
            if (blocks[i].isMine) {
                blocks[i] = { ...blocks[i], isFound: true };
            }
        }
    } else if (!theBlock.nearestMinesCount) {
        this.cleanZeroCountBlock(blocks, index);
    }

    // 触发更新
    this._mineBlocks.next(blocks);

    // 返回判定结果
    return theBlock.isMine;
}

那么到了victoryVerify,它的作用很明显,就是进行胜利判定:当未打开的块的数量等于设定的地雷数量相等的时候,可以被判定为用户胜利。

    private vitoryVerify() {
        // 对当前地雷块数组进行 reduce 查找。
        return this.mineBlocks.reduce((prev, current) => {
            return !current.isMine && current.isFound ? prev + 1 : prev;
        }, 0) === this.mineBlocks.length - this.mineCount;
    }

现在我们已经介绍完这三个函数,下面将分析cleanZeroCountBlock是如何运行的。他的作用就是为了打开当前块附近所有为零的块。

private cleanZeroCountBlock(blocks: IMineBlock[], index: number) {
    const i = index % this.side;
    const j = Math.floor(index / this.side);
    // 对其附近的8个块进行检测
    for (let x = -1; x <= 1; x++) {
        for (let y = -1; y <= 1; y++) {
            const currentIndex = this.transformToIndex(i + x, j + y);
            const block = blocks[currentIndex];
            // 不为原始块,且块存在,且未打开,且不是地雷
            if (currentIndex === index || !block || block.isFound || block.isMine) {
                continue;
            }
            // 将其设为打开状态
            blocks[currentIndex] = { ...block, isFound: true };

            // 递归查询
            if (blocks[currentIndex].nearestMinesCount === 0) {
                this.cleanZeroCountBlock(blocks, currentIndex);
            }
        }
    }
}

到这里,我们基本已经编写完扫雷的具体逻辑。其他相关函数,可以查阅源码,不再赘述。

实现页面

到了这一步,其实就已经完成了大部分的工作,我们根据响应式对象编写组件,然后给dom对象添加点击事件,并触发相关的逻辑函数,之后再做各种的错误处理等等。页面代码就不贴在这里,在Github上可以查看源码。

源码以及参考

最后,如果有写得不好或者存在错误的地方,欢迎提出批评和修改建议,感谢您的阅读。

Mine Sweeper 源码

Angular 官方文档

Rxjs 官方文档