前不久看到约翰·康威逝世的消息,才了解了关于他的一些事情,其中他在1970年发明的生命游戏(Conway's Game of Life,是一种简单的元胞自动机)引起了我的兴趣,于是想尝试实现一下。
JS实现这种简单的平面游戏的话有两个比较直接的选项,一是通过DOM绘制,二是通过canvas绘制。考虑到DOM重绘的性能消耗较大,于是直接采用canvas来进行实现。
wiki中关于生命游戏的规则定义如下:
每个细胞有两种状态 - 存活或死亡,每个细胞与以自身为中心的周围八格细胞产生互动(如图,黑色为存活,白色为死亡)
当前细胞为存活状态时,当周围的存活细胞低于2个时(不包含2个),该细胞变成死亡状态。(模拟生命数量稀少)
当前细胞为存活状态时,当周围有2个或3个存活细胞时,该细胞保持原样。
当前细胞为存活状态时,当周围有超过3个存活细胞时,该细胞变成死亡状态。(模拟生命数量过多)
当前细胞为死亡状态时,当周围有3个存活细胞时,该细胞变成存活状态。(模拟繁殖)
可以把最初的细胞结构定义为种子,当所有在种子中的细胞同时被以上规则处理后,可以得到第一代细胞图。按规则继续处理当前的细胞图,可以得到下一代的细胞图,周而复始。
经过分析之后,总结一下核心逻辑:
构建一个m * n的二维数组,数组有0和1两个值,分别代表生存和死亡,每轮周期根据周围8格的状态分别判断每个数组项的状态并更新。
下面按步骤介绍游戏的实现过程:
- 准备工作,
// 定义常量,棋盘宽度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的棋盘界面
- 增加输入逻辑,在componentDidMount中给
canvas
元素绑定click
和onmousedown
方法捕捉点击和点击拖动事件,然后将触发的区域切换状态并重绘;
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)
}
完成这一步后,你可以通过鼠标在棋盘中点击添加初始状态:
- 实现主循环逻辑
定义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
}
到这一步,游戏的基本逻辑就实现完成,可以通过开始,暂停,停止按钮来运行和重置生命游戏,我们可以通过不同的输入观察到不同的执行结果:
网友评论