用React 编写出简单的小游戏
某个格子的“相邻”格子指它周围的八个格子;
如果一个生命的相邻的格子中包含少于两个生命,则该生命下一回合死亡(人口过少孤独而死);
如果一个生命的相邻格子中包含两个或三个生命,则该生命下一回合存活;
如果一个生命的相邻格子中包含三个以上生命,则该生命下一回合死亡(过于拥挤);
如果一个空格子的相邻格子中包含正好三个生命,则该格子下一回合产生一个生命(繁殖)。
不算第一条关于“相邻”的定义,我们只有四条非常简单的规则。游戏的图像显示很也简单,只是方格的颜色变化而已,所以不需要操作 canvas,用 React就可以很容易地做出来。
如此说来这篇文章也可以算作一篇简单的 React 入门教程。让我们开始吧!
不算第一条关于“相邻”的定义,我们只有四条非常简单的规则。游戏的图像显示很也简单,只是方格的颜色变化而已,所以不需要操作 canvas,用 React就可以很容易地做出来。
如此说来这篇文章也可以算作一篇简单的 React 入门教程。让我们开始吧!
设置 React 环境
首先需要设置 React 环境。
通过 create-react-app(https://github.com/facebook/create-react-app)来创建 React 项目非常方便:
$ npm install -g create-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(https://phaser.io/)。
整个棋盘可以做成一个组件,并渲染成一个<div>。格子怎么办呢?我们不能用一个个<div>来画格子,那样效率太低,而且由于格子是静态的,这样做也没必要。实际上可以用CSS3的linear-gradient画格子。
至于生命则可以用<div>来画。我们将其做成独立的组件,它接收参数x, y,以确定它在棋盘上的位置。
第一步:棋盘
首先来画棋盘。在 src 目录下创建一个文件名为 Game.js,内容如下:
import React from '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上分享的完整代码 https://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={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 logic for 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() 的代码,源代码在这里:
https://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 上找到:https://github.com/charlee/react-gameoflife。