[前端]真的假的?canvas也能玩扫雷?看我手把手教你(附完整代码)

手把手教你用canvas实现扫雷

最近在学前端,学到了canvas。突然意识到其实canvas是一个非常强大的控件!其实能做到基本上所有的图片、图形功能。放着这么强大的功能不去玩一下,怎么对得起我赤诚的心呢。搞起!

在这里插入图片描述

为什么是扫雷

扫雷,于1992年发行。游戏目标是在最短的时间内根据点击格子出现的数字找出所有非雷格子,同时避免踩雷,踩到一个雷即全盘皆输。

据说在设计之初是为了让刚使用电脑的人能熟练使用鼠标的左键、右键、移动操作。结果竟然成了一个挺好玩的游戏,操作简单、玩法上头,直到现在也风韵犹存(?)。所以咱们这次也就选择扫雷作为目标。

扫雷怎么玩?

在扫雷中,有如下几个基本规则:

  • 操作:在棋盘中有若干方块。左键点击某个方块能够打开这个方块,右键点击某个方块能够标记这个方块。
  • 规则1: 一般情况下,一个方块周围有8个方块。如果一个方块周围有雷,那么打开这个方块将会显示周围雷的数量。没有雷则会留空。
  • 规则2: *当标记**方块后,则这个方块将无法被打开(意味着排了一个雷)。玩家可以无限标记。
  • 规则3: 如果打开的方块周围没有雷,则会自动打开周围没有雷的方块。如果周围的方块周围也没有雷,则重复此流程。(递归的打开)
  • 结算: 如果所有雷都被标记,且场上所有非雷方块都被打开,则游戏胜利。相反,如果打开了地雷方块,则游戏失败。如果游戏没有结束,玩家需要再点开格子或标记格子,直到游戏结束。

实现思路

虽然可以用面向过程的角度进行,但写了很长时间Java,总是想使用面向对象的方式。因此可以如下实现:

  • 方块类:记录这个方块的状态(是否被打开、是否是雷等)。有被打开被标记方法。为了游戏本身服务。
  • 游戏类:记录游戏的状态(是否胜利、是否失败等),储存格子信息。有开始结束打开格子方法等。为了玩家操作服务。
  • 界面方法:负责将方块信息渲染到界面上。也就是canvas的操作。
  • 观察者:观测游戏的状态,当状态改变时,回调改变界面的方法,如修改按钮文字等。
  • 其他:包括但不限于 游戏设置、游戏状态、游戏逻辑等。

具体实现

按照思路,首先需要创建一个供玩家操作的对象。这个对象就是canvas。且支持鼠标事件。

鼠标事件分为两种:左键(打开)右键(标记)。左键点击还好说,右键点击就比较麻烦了。因为直接右键就会 :
在这里插入图片描述

在浏览器中,右键点击会触发一个contextmenu事件,而右键点击后,会默认打开一个右键菜单。因此第一步,要先阻止右键菜单的出现。

canvas

html

<div>
  <canvas width="630" height="630" style="margin: 0 auto" />
</div>

js

document.oncontextmenu = function(e) {
    document.addEventListener('contextmenu', (ಠ_ಠ) => { // 突然意识到颜文字也能做变量
        ಠ_ಠ.preventDefault(); // 阻止右键菜单
    })
}

在这里创建了一个canvas,并设置了宽高。随后阻止了右键菜单的出现。注意,这里不能用style指定宽高,否则会导致canvas形变。

再写一个方法,为这个canvas绑定鼠标事件,等初始化的时候调用。

/**
 * 设置鼠标抬起事件监听器
 * 
 * 此函数用于在给定的canvas元素上设置鼠标抬起事件的监听器
 * 当鼠标按钮被释放时,根据点击的类型(左键或右键)执行不同的操作
 * 左键点击时,调用manager的blockOpen方法尝试打开对应的方块
 * 右键点击时,调用manager的blockFlag方法对方块进行标记
 * 
 * @param canvas HTMLCanvasElement类型的目标元素,事件监听器将被设置在这个元素上
 */
function setListener(canvas: HTMLCanvasElement) {
  // 设置鼠标抬起事件监听器
  canvas.onmouseup = (e) => {
    // 左键释放时尝试打开方块
    if (e.button === 0) {
      manager?.blockOpen(Math.floor(e.offsetX / BLOCK_STYLE.width), Math.floor(e.offsetY / BLOCK_STYLE.height));
    } else if (e.button === 2) {
      // 右键标记
      manager?.blockFlag(Math.floor(e.offsetX / BLOCK_STYLE.width), Math.floor(e.offsetY / BLOCK_STYLE.height));
    }
  }
}

然后就能开始编写相关逻辑了。

游戏配置

一个简单的对象,用来记录游戏的配置信息:长几个格子、宽几个格子、出现雷的概率。

将这个对象作为参数传入游戏类,游戏类根据这个对象来创建游戏。这样做的好处是:

  1. 可以通过修改配置来改变游戏难度。方便增加功能。
  2. 可以通过修改配置来改变游戏界面。方便后期调整。
  3. 可以通过修改配置来改变游戏逻辑。方便调试。
let canvas: HTMLCanvasElement; // canvas对象
let ctx: CanvasRenderingContext2D | undefined | null;

// 游戏配置,长宽格子的数量、雷出现的概率。
let SETTING = {
  width: 10,
  height: 10,
  mine: 0.15,
}
// 界面样式,格子的宽度、高度、文字偏移量、字体大小等。
let BLOCK_STYLE = {
  width: 25,
  height: 25,
  xOffset: 10, // 文字x偏移量
  yOffset: 15, // 文字y偏移量
  fontSize: 10,
  // 雷的样式
  mine: {
    color: 'red',
  },
  // 打开的格子样式
  open: {
    color: "#F5F5F5",
    fontcolor: "#333",
  },
  // 标记的格子样式
  flag: {
    color: 'yellow',
  },
  // 未被打开的格子样式
  close: {
    color: "#3b1a8f"
  }
}
// 枚举状态
const enum BlockType {
  Mine,
  Flag,
  Open,
  Close
}
// 当前游戏的状态
let GAME_STATE = {
  isGameOver: true,
}

界面绘制方法

上一步定义了种种的样式,下面就是根据这些样式来绘制界面。通过Block对象获取渲染状态,通过x和y坐标来确定绘制的位置,通过num来确定绘制的数字

/**
 * 绘制方块
 * 
 * 此函数根据方块的类型和位置来绘制方块在画布上的表现它处理不同类型的方块(如关闭、打开、旗 
 * 帜、地雷)的视觉表现,
 * 并根据方块类型使用不同的颜色和样式进行绘制如果方块是打开的并且有计数,该函数还会在方块内绘
 * 制数字
 * 
 * @param block 要绘制的方块对象,包含了方块的类型等信息
 * @param x 方块的横坐标,决定方块在画布上的水平位置
 * @param y 方块的纵坐标,决定方块在画布上的垂直位置
 * @param count 可选参数,表示方块周围的地雷数,仅当方块类型为打开时有效
 */
function drawBlock(block: Block, x: number, y: number, count?: number) {
  // 根据方块类型选择绘制逻辑
  if (block.type === BlockType.Close) {
    // 对于关闭的方块,使用预设的颜色填充矩形
    ctx!.fillStyle = BLOCK_STYLE.close.color;
    ctx!.fillRect(x * BLOCK_STYLE.width, y * BLOCK_STYLE.height, BLOCK_STYLE.width, BLOCK_STYLE.height);

  } else if (block.type === BlockType.Open) {
    // 对于打开的方块,首先使用预设的颜色填充矩形
    ctx!.fillStyle = BLOCK_STYLE.open.color;
    ctx!.fillRect(x * BLOCK_STYLE.width, y * BLOCK_STYLE.height, BLOCK_STYLE.width, BLOCK_STYLE.height);
    // 如果有计数且大于0,则在方块内绘制数字
    if (count && count > 0) {
      ctx!.font = BLOCK_STYLE.fontSize + "px Arial";
      ctx!.fillStyle = BLOCK_STYLE.open.fontcolor;
      ctx!.fillText(count.toString(), x * BLOCK_STYLE.width + BLOCK_STYLE.xOffset, y * BLOCK_STYLE.height + BLOCK_STYLE.yOffset);
    }
  } else if (block.type === BlockType.Flag) {
    // 对于旗帜方块,使用预设的颜色填充矩形
    ctx!.fillStyle = BLOCK_STYLE.flag.color;
    ctx!.fillRect(x * BLOCK_STYLE.width, y * BLOCK_STYLE.height, BLOCK_STYLE.width, BLOCK_STYLE.height);
  } else if (block.type === BlockType.Mine) {
    // 对于地雷方块,使用预设的颜色填充矩形
    ctx!.fillStyle = BLOCK_STYLE.mine.color;
    ctx!.fillRect(x * BLOCK_STYLE.width, y * BLOCK_STYLE.height, BLOCK_STYLE.width, BLOCK_STYLE.height);
  }
}

效果如下
在这里插入图片描述

方块类

方块类,记录方块的状态,有打开、标记等方法。

/**
 * 表示游戏中的一个方块
 * 
 * 这个类定义了一个游戏方块的基本属性和行为,包括方块的类型(关闭、打开、旗帜、地雷)以及是否包含地雷。
 * 它还提供了打开方块、标记方块的方法,并根据方块的状态执行相应的游戏逻辑。
 */
class Block {
  /**
   * 方块的当前类型,默认为关闭状态
   */
  public type = BlockType.Close;

  /**
   * 方块是否包含地雷,默认为随机决定
   */
  public isMine: boolean = false;

  /**
   * 构造函数
   * 
   * 初始化方块是否包含地雷,根据设定的概率随机决定
   */
  constructor() {
    this.isMine = Math.random() > (1 - SETTING.mine);
  }

  /**
   * 打开方块
   * 
   * 将方块的状态设置为打开,如果方块包含地雷,则将状态设置为地雷并触发游戏结束
   */
  public open() {
    this.type = BlockType.Open;
    if (this.isMine) {
      this.type = BlockType.Mine;
      manager?.gameOver(false);
    }
  }

  /**
   * 切换方块标记
   * 
   * 在关闭和旗帜状态之间切换方块,并返回方块是否包含地雷
   * 
   * @returns 返回方块是否包含地雷
   */
  public flag(): boolean {
    if (this.type === BlockType.Flag) this.type = BlockType.Close;
    else this.type = BlockType.Flag;
    return this.isMine;
  }
}

在这里用到了一些manager的方法,这些方法在后面会详细介绍。另外,对于flag方法,它返回了当前方块是否包含地雷,以供后续判断。

创造方块方法

有了方块类,就能创造方块了。在扫雷里,有一个二维数组,用来存储方块对象,通过坐标来定位方块。所以就需要通过循环来创造方块,形成数组。

/**
 * 创建一个二维Block数组
 * @param width 宽度,即二维数组的列数
 * @param height 高度,即二维数组的行数
 * @returns 返回一个二维Block数组,表示一个宽度为width、高度为height的Block矩阵
 */
function createBlocks(width: number, height: number): Block[][] {
  // 使用Array.from生成宽度为width的一维Block数组,然后对每个元素使用Array.from生成高度为height的Block数组
  // 这样就形成一个二维Block数组
  return Array.from({ length: width }, (_, x) =>
    Array.from({ length: height }, (_, y) => {
      // 创建并返回一个新的Block实例
      return new Block();
    }
    )
  )
}

这里使用了Array.from和箭头函数来生成二维数组。如果不愿意也可以使用for循环。

游戏管理类

用来管理游戏状态,包括游戏是否结束、剩余地雷数量等。是暴露给玩家的类。

/**
 * 游戏管理器类
 * 
 * 负责游戏的主要逻辑,如初始化游戏、处理玩家操作、判断游戏结束等。
 */
class GameManager {
  /**
   * Canvas 元素引用
   */
  private canvas: HTMLCanvasElement;

  /**
   * 游戏中的所有方块数组
   */
  private blocks: Block[][] = [];

  /**
   * 地雷数量
   */
  private mineCount: number = 0;

  /**
   * 已标记为地雷的方块数量
   */
  private flagCount: number = 0;

  /**
   * 应该标记的地雷总数
   */
  private allFlagCount: number = 0;

  /**
   * 已打开的方块数量
   */
  private openCount: number = 0;

  /**
   * 观察者对象
   */
  private observer: observer;

  /**
   * 构造函数
   * 
   * 初始化游戏管理器所需的 Canvas 和观察者对象。
   * 
   * @param canvas Canvas 元素
   * @param observer 观察者对象
   */
  constructor(canvas: HTMLCanvasElement, observer: observer) {
    this.canvas = canvas;
    ctx = canvas.getContext('2d');
    this.observer = observer;
  }

  /**
   * 开始游戏
   * 
   * 重置游戏状态,创建新的方块矩阵,并设置监听器和样式。
   */
  public start() {
    GAME_STATE.isGameOver = false;
    this.mineCount = 0;
    this.allFlagCount = 0;
    this.flagCount = 0;
    this.openCount = 0;
    this?.observer?.start();

    this.blocks = createBlocks(SETTING.width, SETTING.height);
    setListener(this.canvas);
    setStyle(this.canvas);
    for (let i = 0; i < SETTING.width; i++) {
      for (let j = 0; j < SETTING.height; j++) {
        const block = this.blocks[i][j];
        if (block.isMine) this.mineCount++;
        drawBlock(block, i, j);
      }
    }
    // 如果没有雷
    if (this.mineCount === 0) {
      this.start();
    }
  }

  /**
   * 结束游戏
   * 
   * 根据是否胜利来更新游戏状态,并通知观察者。
   * 
   * @param isWin 是否胜利
   */
  public gameOver(isWin: boolean) {
    GAME_STATE.isGameOver = true;
    if (isWin) {
      this.observer?.success();
    } else {
      this.observer?.fail();
    }

    logger.success("游戏结束");
    this?.observer?.finish();
  }

  /**
   * 打开指定位置的方块
   * 
   * 检查游戏状态和坐标有效性后,打开方块并递归打开周围无雷的方块。
   * 
   * @param x 方块的横坐标
   * @param y 方块的纵坐标
   */
  public blockOpen(x: number, y: number) {
    if (GAME_STATE.isGameOver) return;
    if (x < 0 || x >= this.blocks.length || y < 0 || y >= this.blocks[0].length) return;

    if (this.blocks[x][y].type === BlockType.Close) {
      this.openCount++;
      this.blocks[x][y].open();
      const num = getNumOfBlock(this.blocks, x, y);
      drawBlock(this.blocks[x][y], x, y, num);
      this.isGameOver();
      if (num === 0) {
        this.blockOpen(x - 1, y);
        this.blockOpen(x + 1, y);
        this.blockOpen(x, y - 1);
        this.blockOpen(x, y + 1);
        this.blockOpen(x - 1, y - 1);
        this.blockOpen(x - 1, y + 1);
        this.blockOpen(x + 1, y - 1);
        this.blockOpen(x + 1, y + 1);
      }
    }
  }

  /**
   * 标记指定位置的方块
   * 
   * 检查游戏状态和坐标有效性后,切换方块的标记状态,并更新相关计数器。
   * 
   * @param x 方块的横坐标
   * @param y 方块的纵坐标
   */
  public blockFlag(x: number, y: number) {
    if (GAME_STATE.isGameOver) return;
    if (x < 0 || x >= this.blocks.length || y < 0 || y >= this.blocks[0].length) return;
    if (this.blocks[x][y].type === BlockType.Close) {
      this.allFlagCount++;
      if (this.blocks[x][y].flag()) this.flagCount++;
      drawBlock(this.blocks[x][y], x, y);
      this.isGameOver();
    } else if (this.blocks[x][y].type === BlockType.Flag) {
      this.allFlagCount--;
      if (this.blocks[x][y].flag()) this.flagCount--;
      drawBlock(this.blocks[x][y], x, y);
      this.isGameOver();
    }
  }

  /**
   * 判断游戏是否结束
   * 
   * 检查已标记的地雷数量是否与实际地雷数量一致,并且已打开的方块数量是否符合胜利条件。
   * 
   * @returns 游戏是否结束
   */
  public isGameOver(): boolean {
    if (this.flagCount === this.mineCount && this.allFlagCount === this.mineCount && SETTING.height * SETTING.width - this.mineCount === this.openCount) {
      this.gameOver(true);
      return true;
    }
    return GAME_STATE.isGameOver;
  }
}

这里就是游戏的主要逻辑了,接下来会详细说明各个部分的逻辑。正所谓知其然知其所以然,慢慢搞总能搞出来。

观察者:observer

观察者模式是一种软件设计模式,它定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。当主题对象状态发生变化时,会通知所有观察者对象,使它们能够自动更新自己。

说人话:

提前告诉小明,如果游戏开始了就改文字;如果游戏赢了就恭喜玩家;如果游戏输了就嘲笑玩家。然后把小明派给游戏管理者。当游戏管理者说:这个玩家赢了!小明就会恭喜这个玩家。其他的也是一样。

observer就是这里的小明。

class observer {
  private onStart: () => void;
  private onFail: () => void;
  private onSuccess: () => void;
  private onFinish: () => void;
  constructor({
    onStart: onStart = () => { },
    onFail: onFail = () => { },
    onSuccess: onSuccess = () => { },
    onFinish: onFinish = () => { },
  }) {
    this.onStart = onStart;
    this.onFail = onFail;
    this.onSuccess = onSuccess;
    this.onFinish = onFinish;
  }

  public start() {
    this.onStart();
  }
  public fail() {
    this.onFail();
  }
  public success() {
    this.onSuccess();
  }
  public finish() {
    this.onFinish();
  }
}

使用方法:

new observer({
  onStart: () => {
    this.startBtnMsg = '重置'
  },
  onFail: () => {
    console.log('onFail')
  },
  onSuccess: () => {
    console.log('onSuccess')
  },
  onFinish: () => {
    this.startBtnMsg = '重新开始'
  },
})

start()方法

顾名思义,在游戏开始时,由玩家调用这个方法。在这里会获取游戏设置并重置游戏状态。同时通知观察者游戏开始,执行开始方法。

最后会确定雷的数量,并将格子进行绘制。如果非常不幸,一个雷都没随机出来,就再次执行这个方法。

其中最主要的是setStyle()方法

setStyle()方法

怎么确定格子的大小呢?如果是一个定值还好。但是往往游戏的宽度和高度是不确定的。同样的画布,可能有10格宽,也可能是15格宽。如果这里写死了格子的大小,会导致部分格子被渲染到画布的外面。因此,需要通过游戏配置来计算格子的大小宽度、字体大小、字体偏移等。

/**
 * 设置画布样式
 * 
 * 此函数的目的是根据给定的画布元素,计算并设置块样式对象(BLOCK_STYLE)
 * 样式设置包括宽度、高度、字体大小以及X和Y轴的偏移量
 * 这些样式将用于后续的绘图操作,以确保图形按照预期的尺寸和位置绘制在画布上
 * 
 * @param canvas 一个HTML画布元素,用于获取画布的宽度和高度
 */
function setStyle(canvas: HTMLCanvasElement) {
  // 获取画布的宽度和高度
  const width = canvas.width;
  const height = canvas.height;

  // 根据画布和设置的宽度比例计算并设置BLOCK_STYLE的宽度
  // 这样可以确保BLOCK_STYLE的宽度与画布的宽度成比例
  BLOCK_STYLE.width = width / SETTING.width;
  
  // 类似地,根据画布和设置的高度比例计算并设置BLOCK_STYLE的高度
  BLOCK_STYLE.height = height / SETTING.height;

  // 计算并设置字体大小,使用了与宽度相关的计算公式
  // 这个大小是根据画布宽度的一个比例来确定的
  BLOCK_STYLE.fontSize = width / SETTING.width / 5 * 2;
  
  // 设置X轴偏移量,它决定了绘制时相对于块宽度的偏移量
  BLOCK_STYLE.xOffset = width / SETTING.width / 5 * 2;
  
  // 设置Y轴偏移量,它决定了绘制时相对于块高度的偏移量
  BLOCK_STYLE.yOffset = height / SETTING.height / 5 * 3;
}

动态修改了样式,就能保证不管宽度和高度如何变化,格子大小和字体大小都是符合比例的。

gameOver(isWin: boolean)方法

相对于开始的方法,这个方法就非常简单明了了。游戏结束,设置游戏状态,根据是否胜利,通知观察者执行不同的方法。最后通知观察者游戏已经结束了。

blockOpen(x: number, y: number)方法

这个方法就是最核心的方法了,是扫雷的灵魂。扫雷的核心就是不断的打开格子,所以是最复杂的方法。

public blockOpen(x: number, y: number) {
  if (GAME_STATE.isGameOver) return; // 游戏以前已经结束,直接返回
  if (x < 0 || x >= this.blocks.length || y < 0 || y >= this.blocks[0].length) return; // 要打开的格子不存在,直接返回
  if (this.blocks[x][y].type === BlockType.Close) { // 如果这个格子没有打开,才能执行逻辑
    this.openCount++; // 记录一下打开的格子数量:恭喜你,又打开了一个格子
    this.blocks[x][y].open(); // 执行格子的打开方法,改变格子的状态
    const num = getNumOfBlock(this.blocks, x, y); // 获取当前格子的数字,也就是格子周围有几个雷
    drawBlock(this.blocks[x][y], x, y, num); // 绘制格子
    this.isGameOver(); // 判断游戏是否结束
    if (num === 0) { // 这个格子上没有数字,也就是周围没有雷,就继续打开周围的格子
      this.blockOpen(x - 1, y); // 向左
      this.blockOpen(x + 1, y); // 向右
      this.blockOpen(x, y - 1); // 向上
      this.blockOpen(x, y + 1); // 向下
      this.blockOpen(x - 1, y - 1); // 左上
      this.blockOpen(x - 1, y + 1); // 左下
      this.blockOpen(x + 1, y - 1); // 右上
      this.blockOpen(x + 1, y + 1); // 右下
    }
  }
}
getNumOfBlock(blocks: Block[][], x: number, y: number)方法

这个方法就是用来获取当前格子上的数字的,也就是周围有几个雷。遍历一遍就行。

/**
 * 计算给定坐标周围 mine 块的数量
 * 
 * 该函数用于确定在一个二维网格中,给定坐标的单元格周围有多少个地雷。它通过检查
 * 指定坐标(x, y)的八个相邻单元格以及对角相邻单元格来计算总数。
 * 
 * @param blocks 一个二维数组,表示游戏板
 * @param x 横坐标,表示要检查的单元格的行位置
 * @param y 纵坐标,表示要检查的单元格的列位置
 * @returns 返回周围 mine 块的数量。如果坐标超出边界,则返回0
 */
function getNumOfBlock(blocks: Block[][], x: number, y: number) {
  // 检查坐标是否超出边界条件,如果超出,则返回0
  if (x < 0 || x >= blocks.length || y < 0 || y >= blocks[0].length) {
    return 0;
  }
  let result = 0;
  // 遍历给定坐标的周围单元格,包括对角线上的单元格
  for (let i = x - 1; i <= x + 1; i++) {
    for (let j = y - 1; j <= y + 1; j++) {
      // 跳过坐标本身,只计算周围的 mine 数量
      if (i === x && j === y) continue;
      // 检查相邻单元格是否包含 mine,如果包含,则计数增加
      if (i >= 0 && i < blocks.length && j >= 0 && j < blocks[0].length && blocks[i][j].isMine) {
        result++;
      }
    }
  }
  // 返回周围 mine 块的总数
  return result;
}

blockFlag(x: number, y: number)方法

这个方法就是用来标记格子的,也就是给格子打个标记,表示这个格子有雷。

在这里引入了两个变量,一个是标记的数量,一个是正确标记的数量。

只要你标记了一个格子,标记的数量就会增加1。但只有标记了地雷,正确标记的数量才会增加1。这就是为什么block.flag()方法里面有判断是否是地雷的。

isGameOver()方法

这个方法就是判断游戏是否结束的。在这些情况都满足的时候就说明满足有结束的条件了:

  1. 标记的数量 = 雷的数量
  2. 正确标记的数量 = 标记的数量
  3. 打开的格子数量 = 所有格子的数量 - 雷的数量

还是挺简单的吧。

单例模式

单例模式指的是一个类只能创建一个实例,并且这个实例可以全局访问。

在这个扫雷游戏里。不论我如何操作,如何游玩。只要我没刷新,我就希望始终有一个唯一的游戏实例。这样才能保证游戏配置的统一。将这个方法暴露给玩家,让玩家不要自己初始化。

export function getInstance(canvas: HTMLCanvasElement, observer: observer) {
  if (!manager) {
    manager = new GameManager(canvas, observer);
  }
  return manager;
}

如果没有单例模式会怎么样?

在每次游戏初始化的时候,会调用manager的构造方法。如果在未来,配置了切换难度功能。如果我切换了难度,调用构造方法意味着新建了一个manager实例,导致之前的manager实例丢失了。

切换难度

因为在之前已经为style做足了适配,所以只需要修改宽高和雷的数量即可。


export function easyS() {
  SETTING.width = 7;
  SETTING.height = 7;
  SETTING.mine = 0.05;
}

export function easy() {
  SETTING.width = 10;
  SETTING.height = 10;
  SETTING.mine = 0.1;
}

export function normal() {
  SETTING.width = 15;
  SETTING.height = 15;
  SETTING.mine = 0.2;
}

export function hard() {
  SETTING.width = 20;
  SETTING.height = 20;
  SETTING.mine = 0.3;
}

export function hardS() {
  SETTING.width = 20;
  SETTING.height = 20;
  SETTING.mine = 0.5;
}

总结

因为用的vue写的,所以先贴全部代码放在这里,大家看着自己改改就行。

代码

index.vue:

<template>
  <div>
    <h3>扫雷</h3>
    <div>
      <canvas width="630" height="630" style="margin: 0 auto" />
    </div>
    <div>
      <div class="button start" @click="manager.start()">{{ startBtnMsg }}</div>
    </div>
  </div>
</template>

<script>
import { getInstance } from './main.ts'
import observer from './Observser.ts'
export default {
  data() {
    return {
      manager: undefined,
      startBtnMsg: '开始',
    }
  },
  mounted() {
    this.$nextTick(() => {
      this.manager = getInstance(
        this.$el.querySelector('canvas'),
        new observer({
          onStart: () => {
            this.startBtnMsg = '重置'
          },
          onFail: () => {
            console.log('onFail')
          },
          onSuccess: () => {
            console.log('onSuccess')
          },
          onFinish: () => {
            this.startBtnMsg = '重新开始'
          },
        })
      )
      this.manager.start()
      document.addEventListener('contextmenu', (ಠ_ಠ) => {
        ಠ_ಠ.preventDefault()
      })
    })
  },
}
</script>

<style>
.button {
  width: 100%;
  height: 50px;
  line-height: 50px;
  text-align: center;
  font-weight: bold;
  border: 1px solid #333;
  cursor: pointer;
  background-color: #fff;
  color: #333;
  box-shadow: 0 0 5px #333;
  border-radius: 5px;
  margin: auto;
  transition: all 0.3s;
}
.button:hover {
  background-color: #333;
  color: #fff;
  box-shadow: 0 0 5px #fff;
  border-radius: 5px;
}
.button:active {
  background-color: #fff;
  color: #333;
  box-shadow: 0 0 5px #333;
  border-radius: 5px;
}
</style>

main.ts:

import { logger } from "@/utils/FeiLogger";
import observer from "./Observser"

let SETTING = {
  width: 10,
  height: 10,
  mine: 0.15,
}

let BLOCK_STYLE = {
  width: 25,
  height: 25,
  xOffset: 10,
  yOffset: 15,
  fontSize: 10,
  mine: {
    color: 'red',
  },
  open: {
    color: "#F5F5F5",
    fontcolor: "#333",
  },
  flag: {
    color: 'yellow',
  },
  close: {
    color: "#3b1a8f"
  }

}

// 枚举状态
const enum BlockType {
  Mine,
  Flag,
  Open,
  Close
}

let manager: GameManager | undefined = undefined;
let ctx: CanvasRenderingContext2D | undefined | null = undefined;

/**
 * 绘制方块
 * 
 * 此函数根据方块的类型和位置来绘制方块在画布上的表现它处理不同类型的方块(如关闭、打开、旗帜、地雷)的视觉表现,
 * 并根据方块类型使用不同的颜色和样式进行绘制如果方块是打开的并且有计数,该函数还会在方块内绘制数字
 * 
 * @param block 要绘制的方块对象,包含了方块的类型等信息
 * @param x 方块的横坐标,决定方块在画布上的水平位置
 * @param y 方块的纵坐标,决定方块在画布上的垂直位置
 * @param count 可选参数,表示方块周围的地雷数,仅当方块类型为打开时有效
 */
function drawBlock(block: Block, x: number, y: number, count?: number) {
  // 根据方块类型选择绘制逻辑
  if (block.type === BlockType.Close) {
    // 对于关闭的方块,使用预设的颜色填充矩形
    ctx!.fillStyle = BLOCK_STYLE.close.color;
    ctx!.fillRect(x * BLOCK_STYLE.width, y * BLOCK_STYLE.height, BLOCK_STYLE.width, BLOCK_STYLE.height);

  } else if (block.type === BlockType.Open) {
    // 对于打开的方块,首先使用预设的颜色填充矩形
    ctx!.fillStyle = BLOCK_STYLE.open.color;
    ctx!.fillRect(x * BLOCK_STYLE.width, y * BLOCK_STYLE.height, BLOCK_STYLE.width, BLOCK_STYLE.height);
    // 如果有计数且大于0,则在方块内绘制数字
    if (count && count > 0) {
      ctx!.font = BLOCK_STYLE.fontSize + "px Arial";
      ctx!.fillStyle = BLOCK_STYLE.open.fontcolor;
      ctx!.fillText(count.toString(), x * BLOCK_STYLE.width + BLOCK_STYLE.xOffset, y * BLOCK_STYLE.height + BLOCK_STYLE.yOffset);
    }
  } else if (block.type === BlockType.Flag) {
    // 对于旗帜方块,使用预设的颜色填充矩形
    ctx!.fillStyle = BLOCK_STYLE.flag.color;
    ctx!.fillRect(x * BLOCK_STYLE.width, y * BLOCK_STYLE.height, BLOCK_STYLE.width, BLOCK_STYLE.height);
  } else if (block.type === BlockType.Mine) {
    // 对于地雷方块,使用预设的颜色填充矩形
    ctx!.fillStyle = BLOCK_STYLE.mine.color;
    ctx!.fillRect(x * BLOCK_STYLE.width, y * BLOCK_STYLE.height, BLOCK_STYLE.width, BLOCK_STYLE.height);
  }
}

/**
 * 创建一个二维Block数组
 * @param width 宽度,即二维数组的列数
 * @param height 高度,即二维数组的行数
 * @returns 返回一个二维Block数组,表示一个宽度为width、高度为height的Block矩阵
 */
function createBlocks(width: number, height: number): Block[][] {
  // 使用Array.from生成宽度为width的一维Block数组,然后对每个元素使用Array.from生成高度为height的Block数组
  // 这样就形成一个二维Block数组
  return Array.from({ length: width }, (_, x) =>
    Array.from({ length: height }, (_, y) => {
      // 创建并返回一个新的Block实例
      return new Block();
    }
    )
  )
}

/**
 * 计算给定坐标周围 mine 块的数量
 * 
 * 该函数用于确定在一个二维网格中,给定坐标的单元格周围有多少个 mine 块它通过检查
 * 指定坐标(x, y)的八个相邻单元格以及对角相邻单元格来计算总数如果相邻单元格包含 mine,
 * 则计数增加该函数不包括坐标本身的 mine 计数
 * 
 * @param blocks 一个二维数组,表示游戏板,其中每个单元格可以包含一个布尔值指示是否存在 mine
 * @param x 横坐标,表示要检查的单元格的行位置
 * @param y 纵坐标,表示要检查的单元格的列位置
 * @returns 返回周围 mine 块的数量如果坐标超出边界,则返回0
 */
function getNumOfBlock(blocks: Block[][], x: number, y: number) {
  // 检查坐标是否超出边界条件,如果超出,则返回0
  if (x < 0 || x >= blocks.length || y < 0 || y >= blocks[0].length) {
    return 0;
  }
  let result = 0;
  // 遍历给定坐标的周围单元格,包括对角线上的单元格
  for (let i = x - 1; i <= x + 1; i++) {
    for (let j = y - 1; j <= y + 1; j++) {
      // 跳过坐标本身,只计算周围的 mine 数量
      if (i === x && j === y) continue;
      // 检查相邻单元格是否包含 mine,如果包含,则计数增加
      if (i >= 0 && i < blocks.length && j >= 0 && j < blocks[0].length && blocks[i][j].isMine) {
        result++;
      }
    }
  }
  // 返回周围 mine 块的总数
  return result;
}

/**
 * 设置鼠标抬起事件监听器
 * 
 * 此函数用于在给定的canvas元素上设置鼠标抬起事件的监听器
 * 当鼠标按钮被释放时,根据点击的类型(左键或右键)执行不同的操作
 * 左键点击时,调用manager的blockOpen方法尝试打开对应的方块
 * 右键点击时,调用manager的blockFlag方法对方块进行标记
 * 
 * @param canvas HTMLCanvasElement类型的目标元素,事件监听器将被设置在这个元素上
 */
function setListener(canvas: HTMLCanvasElement) {
  // 设置鼠标抬起事件监听器
  canvas.onmouseup = (e) => {
    // 左键释放时尝试打开方块
    if (e.button === 0) {
      manager?.blockOpen(Math.floor(e.offsetX / BLOCK_STYLE.width), Math.floor(e.offsetY / BLOCK_STYLE.height));
    } else if (e.button === 2) {
      // 右键标记
      manager?.blockFlag(Math.floor(e.offsetX / BLOCK_STYLE.width), Math.floor(e.offsetY / BLOCK_STYLE.height));
    }
  }
}

/**
 * 设置画布样式
 * 
 * 此函数的目的是根据给定的画布元素,计算并设置块样式对象(BLOCK_STYLE)
 * 样式设置包括宽度、高度、字体大小以及X和Y轴的偏移量
 * 这些样式将用于后续的绘图操作,以确保图形按照预期的尺寸和位置绘制在画布上
 * 
 * @param canvas 一个HTML画布元素,用于获取画布的宽度和高度
 */
function setStyle(canvas: HTMLCanvasElement) {
  // 获取画布的宽度和高度
  const width = canvas.width;
  const height = canvas.height;

  // 根据画布和设置的宽度比例计算并设置BLOCK_STYLE的宽度
  // 这样可以确保BLOCK_STYLE的宽度与画布的宽度成比例
  BLOCK_STYLE.width = width / SETTING.width;

  // 类似地,根据画布和设置的高度比例计算并设置BLOCK_STYLE的高度
  BLOCK_STYLE.height = height / SETTING.height;

  // 计算并设置字体大小,使用了与宽度相关的计算公式
  // 这个大小是根据画布宽度的一个比例来确定的
  BLOCK_STYLE.fontSize = width / SETTING.width / 5 * 2;

  // 设置X轴偏移量,它决定了绘制时相对于块宽度的偏移量
  BLOCK_STYLE.xOffset = width / SETTING.width / 5 * 2;

  // 设置Y轴偏移量,它决定了绘制时相对于块高度的偏移量
  BLOCK_STYLE.yOffset = height / SETTING.height / 5 * 3;
}

/**
 * 游戏管理器类
 * 
 * 负责游戏的主要逻辑,如初始化游戏、处理玩家操作、判断游戏结束等。
 */
class GameManager {
  /**
   * Canvas 元素引用
   */
  private canvas: HTMLCanvasElement;

  /**
   * 游戏中的所有方块数组
   */
  private blocks: Block[][] = [];

  /**
   * 地雷数量
   */
  private mineCount: number = 0;

  /**
   * 已标记为地雷的方块数量
   */
  private flagCount: number = 0;

  /**
   * 应该标记的地雷总数
   */
  private allFlagCount: number = 0;

  /**
   * 已打开的方块数量
   */
  private openCount: number = 0;

  /**
   * 观察者对象
   */
  private observer: observer;

  /**
   * 构造函数
   * 
   * 初始化游戏管理器所需的 Canvas 和观察者对象。
   * 
   * @param canvas Canvas 元素
   * @param observer 观察者对象
   */
  constructor(canvas: HTMLCanvasElement, observer: observer) {
    this.canvas = canvas;
    ctx = canvas.getContext('2d');
    this.observer = observer;
  }

  /**
   * 开始游戏
   * 
   * 重置游戏状态,创建新的方块矩阵,并设置监听器和样式。
   */
  public start() {
    GAME_STATE.isGameOver = false;
    this.mineCount = 0;
    this.allFlagCount = 0;
    this.flagCount = 0;
    this.openCount = 0;
    this?.observer?.start();

    this.blocks = createBlocks(SETTING.width, SETTING.height);
    setListener(this.canvas);
    setStyle(this.canvas);
    for (let i = 0; i < SETTING.width; i++) {
      for (let j = 0; j < SETTING.height; j++) {
        const block = this.blocks[i][j];
        if (block.isMine) this.mineCount++;
        drawBlock(block, i, j);
      }
    }
    // 如果没有雷
    if (this.mineCount === 0) {
      this.start();
    }
  }

  /**
   * 结束游戏
   * 
   * 根据是否胜利来更新游戏状态,并通知观察者。
   * 
   * @param isWin 是否胜利
   */
  public gameOver(isWin: boolean) {
    GAME_STATE.isGameOver = true;
    if (isWin) {
      this.observer?.success();
    } else {
      this.observer?.fail();
    }

    logger.success("游戏结束");
    this?.observer?.finish();
  }

  /**
   * 打开指定位置的方块
   * 
   * 检查游戏状态和坐标有效性后,打开方块并递归打开周围无雷的方块。
   * 
   * @param x 方块的横坐标
   * @param y 方块的纵坐标
   */
  public blockOpen(x: number, y: number) {
    if (GAME_STATE.isGameOver) return;
    if (x < 0 || x >= this.blocks.length || y < 0 || y >= this.blocks[0].length) return;

    if (this.blocks[x][y].type === BlockType.Close) {
      this.openCount++;
      this.blocks[x][y].open();
      const num = getNumOfBlock(this.blocks, x, y);
      drawBlock(this.blocks[x][y], x, y, num);
      this.isGameOver();
      if (num === 0) {
        this.blockOpen(x - 1, y);
        this.blockOpen(x + 1, y);
        this.blockOpen(x, y - 1);
        this.blockOpen(x, y + 1);
        this.blockOpen(x - 1, y - 1);
        this.blockOpen(x - 1, y + 1);
        this.blockOpen(x + 1, y - 1);
        this.blockOpen(x + 1, y + 1);
      }
    }
  }

  /**
   * 标记指定位置的方块
   * 
   * 检查游戏状态和坐标有效性后,切换方块的标记状态,并更新相关计数器。
   * 
   * @param x 方块的横坐标
   * @param y 方块的纵坐标
   */
  public blockFlag(x: number, y: number) {
    if (GAME_STATE.isGameOver) return;
    if (x < 0 || x >= this.blocks.length || y < 0 || y >= this.blocks[0].length) return;
    if (this.blocks[x][y].type === BlockType.Close) {
      this.allFlagCount++;
      if (this.blocks[x][y].flag()) this.flagCount++;
      drawBlock(this.blocks[x][y], x, y);
      this.isGameOver();
    } else if (this.blocks[x][y].type === BlockType.Flag) {
      this.allFlagCount--;
      if (this.blocks[x][y].flag()) this.flagCount--;
      drawBlock(this.blocks[x][y], x, y);
      this.isGameOver();
    }
  }

  /**
   * 判断游戏是否结束
   * 
   * 检查已标记的地雷数量是否与实际地雷数量一致,并且已打开的方块数量是否符合胜利条件。
   * 
   * @returns 游戏是否结束
   */
  public isGameOver(): boolean {
    if (this.flagCount === this.mineCount && this.allFlagCount === this.mineCount && SETTING.height * SETTING.width - this.mineCount === this.openCount) {
      this.gameOver(true);
      return true;
    }
    return GAME_STATE.isGameOver;
  }
}

let GAME_STATE = {
  isGameOver: true,
}

/**
 * 表示游戏中的一个方块
 * 
 * 这个类定义了一个游戏方块的基本属性和行为,包括方块的类型(关闭、打开、旗帜、地雷)以及是否包含地雷。
 * 它还提供了打开方块、标记方块的方法,并根据方块的状态执行相应的游戏逻辑。
 */
class Block {
  /**
   * 方块的当前类型,默认为关闭状态
   */
  public type = BlockType.Close;

  /**
   * 方块是否包含地雷,默认为随机决定
   */
  public isMine: boolean = false;

  /**
   * 构造函数
   * 
   * 初始化方块是否包含地雷,根据设定的概率随机决定
   */
  constructor() {
    this.isMine = Math.random() > (1 - SETTING.mine);
  }

  /**
   * 打开方块
   * 
   * 将方块的状态设置为打开,如果方块包含地雷,则将状态设置为地雷并触发游戏结束
   */
  public open() {
    this.type = BlockType.Open;
    if (this.isMine) {
      this.type = BlockType.Mine;
      manager?.gameOver(false);
    }
  }

  /**
   * 切换方块标记
   * 
   * 在关闭和旗帜状态之间切换方块,并返回方块是否包含地雷
   * 
   * @returns 返回方块是否包含地雷
   */
  public flag(): boolean {
    if (this.type === BlockType.Flag) this.type = BlockType.Close;
    else this.type = BlockType.Flag;
    return this.isMine;
  }
}

export function getInstance(canvas: HTMLCanvasElement, observer: observer) {
  if (!manager) {
    manager = new GameManager(canvas, observer);
  }
  return manager;
}

export function easyS() {
  SETTING.width = 7;
  SETTING.height = 7;
  SETTING.mine = 0.05;
}

export function easy() {
  SETTING.width = 10;
  SETTING.height = 10;
  SETTING.mine = 0.1;
}

export function normal() {
  SETTING.width = 15;
  SETTING.height = 15;
  SETTING.mine = 0.2;
}

export function hard() {
  SETTING.width = 20;
  SETTING.height = 20;
  SETTING.mine = 0.3;
}

export function hardS() {
  SETTING.width = 20;
  SETTING.height = 20;
  SETTING.mine = 0.5;
}

observer.ts

class observer {
  private onStart: () => void;
  private onFail: () => void;
  private onSuccess: () => void;
  private onFinish: () => void;
  constructor({
    onStart: onStart = () => { },
    onFail: onFail = () => { },
    onSuccess: onSuccess = () => { },
    onFinish: onFinish = () => { },
  }) {
    this.onStart = onStart;
    this.onFail = onFail;
    this.onSuccess = onSuccess;
    this.onFinish = onFinish;
  }

  public start() {
    this.onStart();
  }
  public fail() {
    this.onFail();
  }
  public success() {
    this.onSuccess();
  }
  public finish() {
    this.onFinish();
  }
}

export default observer;

效果

在这里插入图片描述

经验

确实,这次对canvas的各种使用。只能说确实很有意思。当玩起自己写的扫雷感觉更好了。

具体体验是很好的,甚至没出什么bug。就算真的出了bug,也基本能定位到问题。只能说运气不错。

  1. 关于canvas的绘制,其实有更多操作,但是没有用到。大家如果感兴趣可以为格子增加一个边框
  2. 关于面向对象的使用,感觉还是有点问题。如不够解耦、职责没有分开等。可以再改改。
  3. 关于生命周期。大家如果使用html,需要再canvas渲染完成后调用方法。
  4. 虽然这条没什么用。但是我发现js对变量的要求基本可以说成没有要求。所以甚至能用颜文字来做变量(**) 话说不会真有人这么做吧
    在这里插入图片描述

未来(?)

  1. 优化代码,使其更简洁,更符合规范。
  2. 将地雷由概率出现改为固定数量。
  3. 优化逻辑:第一次必然不会是地雷
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

飛_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值