前不久看到約翰·康威逝世的消息勾给,才了解了關(guān)于他的一些事情袋毙,其中他在1970年發(fā)明的生命游戲(Conway's Game of Life沪摄,是一種簡(jiǎn)單的元胞自動(dòng)機(jī))引起了我的興趣诱篷,于是想嘗試實(shí)現(xiàn)一下。
JS實(shí)現(xiàn)這種簡(jiǎn)單的平面游戲的話有兩個(gè)比較直接的選項(xiàng)崭孤,一是通過(guò)DOM繪制类嗤,二是通過(guò)canvas繪制糊肠。考慮到DOM重繪的性能消耗較大遗锣,于是直接采用canvas來(lái)進(jìn)行實(shí)現(xiàn)货裹。
wiki中關(guān)于生命游戲的規(guī)則定義如下:
每個(gè)細(xì)胞有兩種狀態(tài) - 存活或死亡,每個(gè)細(xì)胞與以自身為中心的周?chē)烁窦?xì)胞產(chǎn)生互動(dòng)(如圖精偿,黑色為存活弧圆,白色為死亡)
當(dāng)前細(xì)胞為存活狀態(tài)時(shí),當(dāng)周?chē)拇婊罴?xì)胞低于2個(gè)時(shí)(不包含2個(gè))笔咽,該細(xì)胞變成死亡狀態(tài)搔预。(模擬生命數(shù)量稀少)
當(dāng)前細(xì)胞為存活狀態(tài)時(shí),當(dāng)周?chē)?個(gè)或3個(gè)存活細(xì)胞時(shí)叶组,該細(xì)胞保持原樣拯田。
當(dāng)前細(xì)胞為存活狀態(tài)時(shí),當(dāng)周?chē)谐^(guò)3個(gè)存活細(xì)胞時(shí)甩十,該細(xì)胞變成死亡狀態(tài)勿锅。(模擬生命數(shù)量過(guò)多)
當(dāng)前細(xì)胞為死亡狀態(tài)時(shí),當(dāng)周?chē)?個(gè)存活細(xì)胞時(shí)枣氧,該細(xì)胞變成存活狀態(tài)。(模擬繁殖)
可以把最初的細(xì)胞結(jié)構(gòu)定義為種子垮刹,當(dāng)所有在種子中的細(xì)胞同時(shí)被以上規(guī)則處理后达吞,可以得到第一代細(xì)胞圖。按規(guī)則繼續(xù)處理當(dāng)前的細(xì)胞圖荒典,可以得到下一代的細(xì)胞圖酪劫,周而復(fù)始。
經(jīng)過(guò)分析之后寺董,總結(jié)一下核心邏輯:
構(gòu)建一個(gè)m * n的二維數(shù)組覆糟,數(shù)組有0和1兩個(gè)值,分別代表生存和死亡遮咖,每輪周期根據(jù)周?chē)?格的狀態(tài)分別判斷每個(gè)數(shù)組項(xiàng)的狀態(tài)并更新滩字。
下面按步驟介紹游戲的實(shí)現(xiàn)過(guò)程:
- 準(zhǔn)備工作,
// 定義常量御吞,棋盤(pán)寬度50麦箍,高度50,每個(gè)方塊的寬高為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對(duì)象
matrix: [], // 保存二維數(shù)組
isOnGoing: false, // 保存生命游戲的開(kāi)始或停止?fàn)顟B(tài)
}
}
componentDidMount() {
// 獲取canvas的context對(duì)象
const canvas = document.getElementById("game-board")
const ctx = canvas.getContext('2d')
// 在state中保存canvas的context對(duì)象陶珠,并在回調(diào)中執(zhí)行初始化邏輯
this.setState({ ctx }, () => {
this.initBoard()
})
}
// 初始化棋盤(pán)
initBoard() {
// 創(chuàng)建初始二維數(shù)組
const matrix = Array(HEIGHT).fill().map( () => Array(WIDTH).fill(0))
this.setState({ matrix: matrix }, () => {
// 繪制空棋盤(pán)
this.drawMatrix()
})
}
// 繪制棋盤(pán)
drawMatrix() {
const { ctx, matrix } = this.state
// 因?yàn)閏anvas是覆寫(xiě)式的邏輯挟裂,所以繪制之前必須先清空區(qū)域
ctx.clearRect(0,0, WIDTH * ITEM_WIDTH, HEIGHT * ITEM_WIDTH);
// 雙重循環(huán)繪制每個(gè)方格
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>
}
}
實(shí)現(xiàn)這一步之后打開(kāi)當(dāng)前頁(yè)面,你已經(jīng)能看到一個(gè)50 * 50的棋盤(pán)界面
- 增加輸入邏輯揍诽,在componentDidMount中給
canvas
元素綁定click
和onmousedown
方法捕捉點(diǎn)擊和點(diǎn)擊拖動(dòng)事件诀蓉,然后將觸發(fā)的區(qū)域切換狀態(tài)并重繪栗竖;
componentDidMount() {
...
// 綁定點(diǎn)擊事件
canvas.addEventListener('click', this.handleClick.bind(this))
// 綁定點(diǎn)擊拖動(dòng)事件
canvas.onmousedown= (e) => {
//按下后可移動(dòng)
canvas.onmousemove = (e) => {
const x = Math.floor(e.clientX / ITEM_WIDTH)
const y = Math.floor(e.clientY / ITEM_WIDTH)
this.switchSingle(x, y)
};
//鼠標(biāo)抬起清除綁定事件
canvas.onmouseup = function(){
canvas.onmousemove = null;
canvas.onmouseup = null;
};
}
...
}
// 切換并繪制單個(gè)方塊的方法
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 })
// 清空當(dāng)前位置
ctx.clearRect(x * ITEM_WIDTH, y * ITEM_WIDTH, ITEM_WIDTH, ITEM_WIDTH);
// 根據(jù)當(dāng)前狀態(tài)繪制方塊
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);
}
}
// 單擊方塊的回調(diào)方法
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)
}
完成這一步后,你可以通過(guò)鼠標(biāo)在棋盤(pán)中點(diǎn)擊添加初始狀態(tài):
- 實(shí)現(xiàn)主循環(huán)邏輯
定義startGame
,pauseGame
,endGame
三個(gè)方法渠啤,它們分別控制主循壞的開(kāi)始狐肢,暫停和結(jié)束,并將它們分別綁定到“開(kāi)始”埃篓,“暫痛ζ海”, “停止”三個(gè)按鈕上
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)}>開(kāi)始</button>
<button onClick={this.pauseGame.bind(this)}>暫停</button>
<button onClick={this.endGame.bind(this)}>停止</button>
...
</div>
}
traverse
方法是主循壞中執(zhí)行的方法架专,它會(huì)遍歷二維數(shù)組同窘,并對(duì)每個(gè)數(shù)組項(xiàng)調(diào)用check
方法推算其下一步的狀態(tài)
// 遍歷所有方格
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()
})
}
// 檢查當(dāng)前方格
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) {
// 周?chē)?xì)胞數(shù)為3時(shí),一定為1
return 1
} else if(count == 2) {
// 周?chē)?xì)胞數(shù)為2時(shí)部脚,1保持1想邦,0保持0
return matrix[y][x]
} else {
// 其他情況,一定為0
return 0
}
}
// 該方法返回對(duì)應(yīng)坐標(biāo)的0 || 1狀態(tài)
getItemValue(x, y) {
const { matrix } = this.state
return (matrix[y] || [])[x] || 0
}
到這一步委刘,游戲的基本邏輯就實(shí)現(xiàn)完成丧没,可以通過(guò)開(kāi)始,暫停锡移,停止按鈕來(lái)運(yùn)行和重置生命游戲呕童,我們可以通過(guò)不同的輸入觀察到不同的執(zhí)行結(jié)果:
該項(xiàng)目的demo可以點(diǎn)擊連接后,在"生命游戲"路徑下查看
項(xiàng)目的源代碼點(diǎn)擊這里查看