美文网首页JS学习笔记
使用canvas实现康威的生命游戏

使用canvas实现康威的生命游戏

作者: _敏讷 | 来源:发表于2020-04-21 15:15 被阅读0次

前不久看到约翰·康威逝世的消息,才了解了关于他的一些事情,其中他在1970年发明的生命游戏(Conway's Game of Life,是一种简单的元胞自动机)引起了我的兴趣,于是想尝试实现一下。
JS实现这种简单的平面游戏的话有两个比较直接的选项,一是通过DOM绘制,二是通过canvas绘制。考虑到DOM重绘的性能消耗较大,于是直接采用canvas来进行实现。


wiki中关于生命游戏的规则定义如下:

每个细胞有两种状态 - 存活或死亡,每个细胞与以自身为中心的周围八格细胞产生互动(如图,黑色为存活,白色为死亡)

当前细胞为存活状态时,当周围的存活细胞低于2个时(不包含2个),该细胞变成死亡状态。(模拟生命数量稀少)

当前细胞为存活状态时,当周围有2个或3个存活细胞时,该细胞保持原样。

当前细胞为存活状态时,当周围有超过3个存活细胞时,该细胞变成死亡状态。(模拟生命数量过多)

当前细胞为死亡状态时,当周围有3个存活细胞时,该细胞变成存活状态。(模拟繁殖)

可以把最初的细胞结构定义为种子,当所有在种子中的细胞同时被以上规则处理后,可以得到第一代细胞图。按规则继续处理当前的细胞图,可以得到下一代的细胞图,周而复始。

经过分析之后,总结一下核心逻辑:
构建一个m * n的二维数组,数组有0和1两个值,分别代表生存和死亡,每轮周期根据周围8格的状态分别判断每个数组项的状态并更新。

下面按步骤介绍游戏的实现过程:

  1. 准备工作,
// 定义常量,棋盘宽度50,高度50,每个方块的宽高为10
const WIDTH = 50
const HEIGHT = 50
const ITEM_WIDTH = 10

class LifeGame extends Component {
  constructor(props) {
    super(props)
    this.state = {
      ctx: null,        // 保存canva的context对象
      matrix: [],        // 保存二维数组
      isOnGoing: false,        // 保存生命游戏的开始或停止状态
    }
  }
  componentDidMount() {
    // 获取canvas的context对象
    const canvas = document.getElementById("game-board")
    const ctx = canvas.getContext('2d')
    // 在state中保存canvas的context对象,并在回调中执行初始化逻辑
    this.setState({ ctx }, () => {
      this.initBoard()
    })
  }
  // 初始化棋盘
  initBoard() {
    // 创建初始二维数组
    const matrix = Array(HEIGHT).fill().map( () => Array(WIDTH).fill(0))
    this.setState({ matrix: matrix }, () => {
      //  绘制空棋盘
      this.drawMatrix()
    })
  }
  // 绘制棋盘
  drawMatrix() {
    const { ctx, matrix } = this.state
    // 因为canvas是覆写式的逻辑,所以绘制之前必须先清空区域
    ctx.clearRect(0,0, WIDTH * ITEM_WIDTH, HEIGHT * ITEM_WIDTH); 
    // 双重循环绘制每个方格
    matrix.forEach( (arr, y) => {
      arr.forEach( (item, x) => {
        if(item == 1) {
          ctx.fillRect(x * ITEM_WIDTH, y * ITEM_WIDTH, ITEM_WIDTH, ITEM_WIDTH);
        } else {
          ctx.strokeRect(x * ITEM_WIDTH, y * ITEM_WIDTH, ITEM_WIDTH, ITEM_WIDTH);
        }
      })
    })
  }
  render() {
    return <div>
      <canvas width={WIDTH * ITEM_WIDTH} height={HEIGHT * ITEM_WIDTH} id='game-board' className='canvas' />
    </div>
  }
}

实现这一步之后打开当前页面,你已经能看到一个50 * 50的棋盘界面


  1. 增加输入逻辑,在componentDidMount中给canvas元素绑定clickonmousedown方法捕捉点击和点击拖动事件,然后将触发的区域切换状态并重绘;
componentDidMount() {
    ...
    // 绑定点击事件
    canvas.addEventListener('click', this.handleClick.bind(this))
    // 绑定点击拖动事件
    canvas.onmousedown= (e) => {
      //按下后可移动
      canvas.onmousemove = (e) => {
          const x = Math.floor(e.clientX / ITEM_WIDTH)
          const y = Math.floor(e.clientY / ITEM_WIDTH)
          this.switchSingle(x, y)
      };
      //鼠标抬起清除绑定事件
      canvas.onmouseup = function(){
          canvas.onmousemove = null;
          canvas.onmouseup = null;
      };
    }
    ...
}
  // 切换并绘制单个方块的方法
  switchSingle(x, y) {
    const { ctx, matrix } = this.state
    const nextMatrix = cloneDeep(matrix)
    nextMatrix[y][x] = nextMatrix[y][x] == 1 ? 0 : 1
    // 更新state
    this.setState({ matrix: nextMatrix })
    // 清空当前位置
    ctx.clearRect(x * ITEM_WIDTH, y * ITEM_WIDTH, ITEM_WIDTH, ITEM_WIDTH); 
    // 根据当前状态绘制方块
    if(nextMatrix[y][x] == 1) {
      ctx.fillRect(x * ITEM_WIDTH, y * ITEM_WIDTH, ITEM_WIDTH, ITEM_WIDTH);
    } else {
      ctx.strokeRect(x * ITEM_WIDTH, y * ITEM_WIDTH, ITEM_WIDTH, ITEM_WIDTH);
    }
  }
  // 单击方块的回调方法
  handleClick(e) {
    if(!this.state.isOnGoing) {
      const x = Math.floor(e.pageX / ITEM_WIDTH)
      const y = Math.floor(e.pageY / ITEM_WIDTH)
      this.switchSingle(x, y)
    }
    console.log(e.pageX, e.pageY)
  }

完成这一步后,你可以通过鼠标在棋盘中点击添加初始状态:


  1. 实现主循环逻辑
    定义startGame,pauseGame,endGame三个方法,它们分别控制主循坏的开始,暂停和结束,并将它们分别绑定到“开始”,“暂停”, “停止”三个按钮上
  startGame() {
    this.gameLoop = setInterval(() => {
      this.traverse()
    }, 500);
  }

  pauseGame() {
    clearInterval(this.gameLoop)
  }

  endGame() {
    clearInterval(this.gameLoop)
    this.initBoard()
  }

  render() {
    return <div>
        ...
         <button onClick={this.startGame.bind(this)}>开始</button>
        <button onClick={this.pauseGame.bind(this)}>暂停</button>
        <button onClick={this.endGame.bind(this)}>停止</button>
        ...
    </div>
  }

traverse方法是主循坏中执行的方法,它会遍历二维数组,并对每个数组项调用check方法推算其下一步的状态

  // 遍历所有方格
  traverse() {
    const { matrix } = this.state
    const nextMatrix = cloneDeep(matrix)
    matrix.forEach( (arr, y) => {
      arr.forEach( (item, x) => {
        nextMatrix[y][x] = this.check(x, y)
      })
    })
    this.setState({ matrix: nextMatrix}, () => {
      this.drawMatrix()
    })
  }

  // 检查当前方格
  check(x, y) {
    const { matrix } = this.state
    const count =  this.getItemValue(x-1, y - 1) 
      + this.getItemValue(x, y - 1)
      + this.getItemValue(x + 1, y - 1)
      + this.getItemValue(x - 1, y)
      + this.getItemValue(x + 1, y)
      + this.getItemValue(x - 1, y + 1)
      + this.getItemValue(x, y + 1)
      + this.getItemValue(x + 1, y + 1)
    if(count == 3) {
      // 周围细胞数为3时,一定为1
      return 1
    } else if(count == 2) {
      // 周围细胞数为2时,1保持1,0保持0
      return matrix[y][x]
    } else {
      // 其他情况,一定为0
      return 0
    }
  }
  // 该方法返回对应坐标的0 || 1状态
  getItemValue(x, y) {
    const { matrix } = this.state
    return (matrix[y] || [])[x] || 0
  }

到这一步,游戏的基本逻辑就实现完成,可以通过开始,暂停,停止按钮来运行和重置生命游戏,我们可以通过不同的输入观察到不同的执行结果:


该项目的demo可以点击连接后,在"生命游戏"路径下查看
项目的源代码点击这里查看

相关文章

网友评论

    本文标题:使用canvas实现康威的生命游戏

    本文链接:https://www.haomeiwen.com/subject/tlluihtx.html