如何在一小时内用 React 编写出简单的小游戏?
点击上方“
CSDN
”,选择“置顶公众号”
关键时刻,第一时间送达!
最近有个很火的视频叫做“5 分钟编写贪吃蛇”。视频很不错,这种快速编程的方法也很有意思,所以我决定自己也做一个。
我小时候刚开始接触编程时学过一个游戏叫做“康威生命游戏”。它是一个简单的元胞自动机的例子,只需几条非常简单的规则,就可以演化出极其复杂的变化。其内容是,在一个格子棋盘上有许多生命,每个回合这些生命按照一定的规则繁殖或死亡:
某个格
子的“相邻”格子指它周围的八个格子;
如果一个生命的相邻的格子中包含少于两个生命,则该生命下一回合死亡(人口过少孤独而死);
如果一个生命的相邻格子中包含两个或三个生命,则该生命下一回合存活;
如果一个生命的相邻格子中包含三个以上生命,则该生命下一回合死亡(过于拥挤);
如果一个空格子的相邻格子中包含正好三个生命,则该格子下一回合产生一个生命(繁殖)。
不算第一条关于“相邻”的定义,我们只有四条非常简单的规则。游戏的图像显示很也简单,只是方格的颜色变化而已,所以不需要操作 canvas,用 React就可以很容易地做出来。
如此说来这篇文章也可以算作一篇简单的 React 入门教程。让我们开始吧!
设置 React 环境
首先需要设置 React 环境。
通过 create-react-app(
http://github.com/facebook/create-react-app
)来创建 React 项目非常方便:
$ npm
install
-gcreate
-react-app$
create
-react-app react-gameoflife不到一分钟的时间,react-gameoflife 就创建好了。接下来只需要启动它:
$
cd
react-gameoflife$
npm start
这条命令将在 http://localhost:3000 上启动一个开发服务器,并且会自动启动浏览器打开该地址。
实现过程
我们需要实现的最终游戏画面如下所示:
一个简单的格子棋盘,加上一些白色的方块(生命),点击格子可以放置或移除方块。Run 按钮可以按照给定的时间间隔开始回合迭代。
看起来很简单吧?想一想在 React 中怎么做.
必须明确的是,React 不是图形框架,所以这里不会使用 canvas。
如果想用canvas做,可以参考下PIXI(http://www.pixijs.com/)或Phaser(http://phaser.io/)。
整个棋盘可以做成一个组件,并渲染成一个<div>。格子怎么办呢?我们不能用一个个<div>来画格子,那样效率太低,而且由于格子是静态的,这样做也没必要。实际上可以用CSS3的linear-gradient画格子。
至于生命则可以用<div>来画。我们将其做成独立的组件,它接收参数x, y,以确定它在棋盘上的位置。
第一步:棋盘
首先来画棋盘。在 src 目录下创建一个文件名为 Game.js,内容如下:
import
Reactfrom
"react"
;import
"./Game.css"
;const
CELL_SIZE =20
;const
WIDTH =800
;const
HEIGHT =600
;class
Game
extends
React
.Component
{render() {
return
(<
div
><
div
className
="Board"
style
={{
width:
WIDTH
,height:
HEIGHT
}}></
div
></
div
>);
}
}
export
default
Game;还需要 Game.css 来定义样式:
.Board
{position
: relative;margin
:0
auto;background-color
:#000
;}
更新 App.js 导入 Game.js 并将 Game 组件显示出来(代码省略,请参见我在GitHub上分享的完整代码 http://github.com/charlee/react-gameoflife)。现在就能看到一个全黑的棋盘了。
下一步是画格子。只需要一行 linear-gradient 就可以做到(加到 Game.css 中):
background-image:
linear-gradient(
#333 1px, transparent 1px),
linear-gradient(90deg,
#333 1px, transparent 1px);
其实为了让格子能正确显示,我们还得定义 background-size 样式。但由于 Game.js 中定义了 CELL_SIZE 常量,我们希望能通过该常量来定义格子大小,而不是写死在 CSS 中,所以可以用行内样式来直接定义背景大小。
修改 Game.js 中的 style 行:
<div className=
"Board"
style={{
width
: WIDTH,height
: HEIGHT,backgroundSize
:`
${CELL_SIZE}
px${CELL_SIZE}
px`}}>
</
div
>刷新浏览器就能看到漂亮的格子。
创建表示生命的方块
下一步我们要允许用户通过点击棋盘的方式来创建方块。下面的代码中使用 this.board 二维数组来保存棋盘状态,this.state.cells 数组保存生命的位置列表。棋盘状态更新后,调用 this.makeCells() 根据棋盘状态生成新的生命位置列表。
向 Game 类添加以下代码:
class
Game
extends
React
.Component
{constructor
() {super
();this
.rows = HEIGHT / CELL_SIZE;this
.cols = WIDTH / CELL_SIZE;this
.board =this
.makeEmptyBoard();}
state = {
cells: [],
}
// Create an empty board
makeEmptyBoard() {
let board = [];
for
(let y =0
; y <this
.rows; y++) {board[y] = [];
for
(let x =0
; x <this
.cols; x++) {board[y][x] =
false
;}
}
return
board;}
// Create cells from this.board
makeCells() {
let cells = [];
for
(let y =0
; y <this
.rows; y++) {for
(let x =0
; x <this
.cols; x++) {if
(this
.board[y][x]) {cells.push({ x, y });
}
}
}
return
cells;}
...
}
下一步要允许用户通过点击棋盘的方式添加或删除生命。React 可以给 <div> 指定 onClick 事件处理函数,该函数可以通过点击事件的属性来获得点击发生的坐标。但问题是这个事件的坐标是相对于整个客户端区域(即浏览器的可视区域)的,所以需要一些额外的代码将其转换成相对于棋盘的坐标。
向 render() 方法中添加以下事件处理函数。我们同时还保存了棋盘元素的引用,以便稍后获取棋盘的位置。
render() {
return
(<
div
><
div
className
="Board"
style
={{
width:
WIDTH
,height:
HEIGHT
,backgroundSize:
`${CELL_SIZE
}px
${CELL_SIZE
}px
`}}onClick
={this.handleClick}
ref
={(n)
=> { this.boardRef = n; }}></
div
></
div
>);
}
还需要再加几个函数。getElementOffset() 计算棋盘元素的位置。handleClick() 获取点击的位置,转换成相对坐标,再计算被点击的格子所在的行和列。然后反转相应格子的状态。
class
Game
extends
React
.Component
{...
getElementOffset() {
const
rect =this
.boardRef.getBoundingClientRect();const
doc =document
.documentElement;return
{x
: (rect.left +window
.pageXOffset) - doc.clientLeft,y
: (rect.top +window
.pageYOffset) - doc.clientTop,};
}
handleClick =
(
event
) => {const
elemOffset =this
.getElementOffset();const
offsetX = event.clientX - elemOffset.x;const
offsetY = event.clientY - elemOffset.y;const
x =Math
.floor(offsetX / CELL_SIZE);const
y =Math
.floor(offsetY / CELL_SIZE);if
(x >=0
&& x <=this
.cols && y >=0
&& y <=this
.rows) {this
.board[y][x] = !this
.board[y][x];}
this
.setState({cells
:this
.makeCells() });}
...
}
最后,要将 this.state.cells 中方格渲染出来:
class
Cell
extends
React
.Component
{render() {
const { x, y } =
this
.props;return
(<div className=
"Cell"
style={{left: `${CELL_SIZE * x +
1
}px`,top: `${CELL_SIZE * y +
1
}px`,width: `${CELL_SIZE -
1
}px`,height: `${CELL_SIZE -
1
}px`,}} />
);
}
}
class
Game
extends
React
.Component
{...
render() {
const { cells } =
this
.state;return
(<div>
<div className=
"Board"
style={{ width: WIDTH, height: HEIGHT,
backgroundSize: `${CELL_SIZE}px ${CELL_SIZE}px`}}
onClick={
this
.handleClick}ref={(n) => {
this
.boardRef = n; }}>{cells.map(cell => (
<Cell x={cell.x} y={cell.y}
key={`${cell.x},${cell.y}`}/>
))}
</div>
</div>
);
}
...
}
别忘了给 Cell 组件加一些样式(Game.css):
.Cell
{background
:#ccc
;position
: absolute;}
刷新浏览器,试着点一下棋盘。现在可以添加或删除生命了!
运行游戏
我们需要一些辅助的东西来运行游戏。首先添加一些控制元素。
class
Game
extends
React
.Component
{state = {
cells: [],
interval:
100
,isRunning:
false
,}
...
runGame
=()
=> {this
.setState({ isRunning:true
});}
stopGame
=()
=> {this
.setState({ isRunning:false
});}
handleIntervalChange
=(event)
=> {this
.setState({ interval: event.target.value });}
render() {
return
(...
<div className=
"controls"
>Update every <input value=http://www.gunmi.cn/v/{
this
.state.interval}onChange={
this
.handleIntervalChange} /> msec{isRunning ?
<button className=
"button"
onClick={
this
.stopGame}>Stop</button> :<button className=
"button"
onClick={
this
.runGame}>Run</button>}
</div>
...
);
}
}
这些代码会在页面底部添加一个时间间隔输入框,以及一个 Run 按钮。
现在点击 Run 还没有任何效果,因为我们还没有写游戏规则。下面就开始写游戏规则吧。
这个游戏中,每个回合都会更新棋盘状态。因此我们需要一个方法 runIteration(),该方法将以固定的时间间隔调用,比如每 100 毫秒调用一次。这可以通过 window.setTimeout() 实现。
点击 Run 按钮将调用 runIteration() 方法。该方法在结束之前会调用 window.setTimeout(),设置在 100ms 之后重新运行自己。这样 runIteration() 将反复执行。点击 Stop 按钮会调用 window.clearTimeout() 取消安排好的执行,这样就能打断反复执行。
class
Game
extends
React
.Component
{...
runGame
=()
=> {this
.setState({ isRunning:true
});this
.runIteration();}
stopGame
=()
=> {this
.setState({ isRunning:false
});if
(this
.timeoutHandler) {window
.clearTimeout(this
.timeoutHandler);this
.timeoutHandler =null
;}
}
runIteration() {
console
.log("running iteration"
);let newBoard =
this
.makeEmptyBoard();//
TODO: Add logicfor
each iteration here.this
.board = newBoard;this
.setState({ cells:this
.makeCells() });this
.timeoutHandler =window
.setTimeout(()
=> {this
.runIteration();},
this
.state.interval);}
...
}
刷新浏览器并点击“Run”按钮。我们可以在控制台(按 Ctrl-Shift-I 可以调出控制台)中看到“running iteration”的调试信息。
接下来需要给runIteration()方法添加代码以实现游戏规则。回想一下我们的游戏规则:
如果一个生命的相邻的格子中包含少于两个生命,则该生命下一回合死亡。
如果一个生命的相邻格子中包含两个或三个生命,则该生命下一回合存活。
如果一个生命的相邻格子中包含三个以上生命,则该生命下一回合死亡。
如果一个空格子的相邻格子中包含正好三个生命,则该格子下一回合产生一个生命。
我们可以写一个方法 calculateNeighbors() 来计算给定 (x, y) 的相邻格子中的生命数量。
这里省略了 calculateNeighbors() 的代码,源代码在这里:
http://github.com/charlee/react-gameoflife/blob/master/src/Game.js#L134
然后规则就很容易实现了:
for
(let y =0
; y <this
.rows; y++) {for
(let x =0
; x <this
.cols; x++) {let neighbors =
this
.calculateNeighbors(this
.board, x, y);if
(this
.board[y][x]) {if
(neighbors ===2
|| neighbors ===3
) {newBoard[y][x] =
true
;}
else
{newBoard[y][x] =
false
;}
}
else
{if
(!this
.board[y][x] && neighbors ===3
) {newBoard[y][x] =
true
;}
}
}
}
刷新浏览器,放置一些生命,然后点击 Run 按钮,就能看到漂亮的动画了!
总结
最后的项目里我还加了个 Random 和 Clear 按钮,让操作更容易些。完整的代码可以在我的 GitHub 上找到:
http://github.com/charlee/react-gameoflife。
- 土豆“翻身仗”,如何在中国逆袭成“粮”?
- 感受昆山经济脉动 台籍学生现场提问“如何在这里生根?”
- 公孙胜为何在宋江上山后执意离开?只因宋江请来了“九天玄女”
- 迪莉娅包粽子挑战赛15分钟破吉尼斯纪录!一小时包了1866个粽子!
- 南京江宁凶宅拍卖引关注 一小时内加价20万!
- 从稳居行业C位到质押近九成股份,华谊兄弟破局之路何在?
- 如何在捡垃圾游戏《绝地求生》里科学性地塞满背包?
- 政策解读 | 证监会为何在《通知》中三次提到“专业化、机构化”
- 如何在盘中把握主力资金动向的方法分时战法
- 软件被禁用! 武汉这家科技企业为何在西安被罚?