查看原文
其他

Charlee Li 2018-05-30

点击上方“CSDN”,选择“置顶公众号”

关键时刻,第一时间送达!

最近有个很火的视频叫做“5 分钟编写贪吃蛇”。视频很不错,这种快速编程的方法也很有意思,所以我决定自己也做一个。

我小时候刚开始接触编程时学过一个游戏叫做“康威生命游戏”。它是一个简单的元胞自动机的例子,只需几条非常简单的规则,就可以演化出极其复杂的变化。其内容是,在一个格子棋盘上有许多生命,每个回合这些生命按照一定的规则繁殖或死亡:

某个格子的“相邻”格子指它周围的八个格子;

如果一个生命的相邻的格子中包含少于两个生命,则该生命下一回合死亡(人口过少孤独而死);

如果一个生命的相邻格子中包含两个或三个生命,则该生命下一回合存活;

如果一个生命的相邻格子中包含三个以上生命,则该生命下一回合死亡(过于拥挤);

如果一个空格子的相邻格子中包含正好三个生命,则该格子下一回合产生一个生命(繁殖)。

不算第一条关于“相邻”的定义,我们只有四条非常简单的规则。游戏的图像显示很也简单,只是方格的颜色变化而已,所以不需要操作 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。



    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存