Hogyan kódolhatjuk az „Élet játékát” a React segítségével egy órán belül

Nemrégiben megnéztem azt a híres videót, amely kevesebb mint 5 perc alatt létrehoz egy kígyójátékot (a Youtube-on). Nagyon érdekesnek tűnt ilyen típusú gyors kódolás, ezért úgy döntöttem, hogy egyedül csinálom.

Amikor gyermekként elkezdtem tanulni a programozást, megtanultam az „Game of Life” nevű játékot. Remek példa a sejtes automatizálásra és arra, hogy az egyszerű szabályok hogyan eredményezhetnek összetett mintákat. Képzelje el, hogy valamiféle életforma él egy világban. Minden fordulóban néhány egyszerű szabályt követnek annak eldöntésére, hogy egy élet él-e vagy holt.

Conway Életjátéka - Wikipédia

Megjelenése óta a Conway Élet játéka nagy érdeklődést váltott ki, mivel meglepő módon… en.wikipedia.org

Ezért úgy döntöttem, hogy kódolom ezt a játékot. Mivel nem tartalmaz túl sok grafikát - csak egy rácsot és néhány blokkot -, úgy döntöttem, hogy a React jó választás lenne, és a React gyors oktatóanyagaként használható. Kezdjük!

Reagál a beállításra

Először be kell állítanunk a React-et. A csodálatos create-react-appeszköz nagyon hasznos egy új React projekt elindításához:

$ npm install -g create-react-app$ create-react-app react-gameoflife

Kevesebb mint egy perc react-gameoflifemúlva készen áll. Most már csak el kell indítanunk:

$ cd react-gameoflife$ npm start

Ez elindítja a dev szervert a // localhost: 3000 címen, és egy böngészőablak nyílik meg ezen a címen.

Tervezési lehetőségek

Az utolsó képernyő, amelyet szeretnénk elkészíteni, így néz ki:

Ez egyszerűen egy rácsos tábla és néhány fehér csempe („cellák”), amelyeket a rácsra kattintva elhelyezhetünk vagy eltávolíthatunk. A „Futtatás” gomb elindítja az iterációkat egy adott időközönként.

Nagyon egyszerűnek tűnik, mi? Gondoljuk át, hogyan lehet ezt megtenni a React-ben. Először is, a React nem grafikus keret, ezért nem fogunk gondolkodni a vászon használatán. (Vessen egy pillantást a PIXI-re vagy a Phaser-re, ha érdekli a vászon használata.)

A tábla lehet komponens, és egyetlen egységgel is megjeleníthető iv>. How about the grid? It’s not feasible to draw the grids with s, and since the grid is static, it is also unnecessary. Indeed we c an use CSS3 linear-gradient for the grid.

In regard to the cells, we can use iv> to draw each cell. We will make it a separate component. This component ac cep ts x, y as inputs so that the board can specify its position.

First step: the board

Let’s create the board first. Create a file called Game.js under the src directory and type in the following code:

import React from 'react';import './Game.css';
const CELL_SIZE = 20;const WIDTH = 800;const HEIGHT = 600;
class Game extends React.Component { render() { return ( ); }}
export default Game;

We also need the Game.css file to define the styles:

.Board { position: relative; margin: 0 auto; background-color: #000;}

Update App.js to import our Game.js and place the Game component on the screen. Now we can see a completely black game board.

Our next step is to create the grid. The grid can be created with only one line of linear-gradient (add this to Game.css):

background-image: linear-gradient(#333 1px, transparent 1px), linear-gradient(90deg, #333 1px, transparent 1px);

In fact, we need to specify background-size style as well to make it work. But since the CELL_SIZE constant is defined in Game.js, we will specify background size with inline style directly. Change the style line in Game.js:

Refresh the browser and you will see a nice grid.

Create the cells

The next step is to allow the user to interact with the board to create the cells. We will use a 2D array this.board to keep the board state, and a cell list this.state.cells to keep the position of the cells. Once the board state is updated, a method this.makeCells() will be called to generate the cell list from the board state.

Add these methods to the Game class:

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; } ...}

Next, we will allow the user to click the board to place or remove a cell. In React, iv> can be attached wi th an onClick event handler, which could retrieve the click coordinate through the click event. However the coordinate is relative to the client area (the visible area of the browser), so we need some extra code to convert it to a coordinate that is relative to the board.

Add the event handler to the render() method. Here we also save the reference of the board element in order to retrieve the board location later.

render() { return ( { this.boardRef = n; }}> );}

And here are some more methods. Here getElementOffset() will calculate the position of the board element. handleClick() will retrieve the click position, then convert it to relative position, and calculate the cols and rows of the cell being clicked. Then the cell state is reverted.

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 = 0 && y <= this.rows) { this.board[y][x] = !this.board[y][x]; }
 this.setState({ cells: this.makeCells() }); } ...}

As the last step, we will render the cells this.state.cells to the board:

class Cell extends React.Component { render() { const { x, y } = this.props; return ( ); }}
class Game extends React.Component { ... render() { const { cells } = this.state; return ( { this.boardRef = n; }}> {cells.map(cell => ( ))} ); } ...}

And don’t forget to add styles for the Cell component (in Game.css):

.Cell { background: #ccc; position: absolute;}

Refresh the browser and try to click the board. The cells can be placed or removed now!

Run the Game

Now we need some helpers to run the game. First let’s add some controllers.

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 ( ... Update every  msec {isRunning ? Stop : Run } ... ); }}

This code will add one interval input and one button to the bottom of the screen.

Note that clicking Run has no effect, because we haven’t written anything to run the game. So let’s do that now.

In this game, the board state is updated every iteration. Thus we need a method runIteration() to be called every iteration, say, 100ms. This can be achieved by using window.setTimeout().

When the Run button is clicked, runIteration() will be called. Before it ends, it will call window.setTimeout() to arrange another iteration after 100msec. In this way, runIteration() will be called repeatedly. When the Stop button is clicked, we will cancel the arranged timeout by calling window.clearTimeout() so that the iterations can be stopped.

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); } ...}

Reload the browser and click the “Run” button. We will see the log message “running iteration” in the console. (If you don’t know how to show the console, try pressing Ctrl-Shift-I.)

Now we need to add the game rules to runIteration() method. According to Wikipedia, the Game of Life has four rules:

1. Any live cell with fewer than two live neighbors dies, as if caused by under population.2. Any live cell with two or three live neighbors lives on to the next generation.3. Any live cell with more than three live neighbors dies, as if by overpopulation.4. Any dead cell with exactly three live neighbors becomes a live cell, as if by reproduction.

We can add a method calculateNeighbors() to compute the number of neighbors of given (x, y). (The source code of calcualteNeighbors() will be omitted in this post, but you can find it here.) Then we can implement the rules in a straightforward way:

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; } } }}

Reload the browser, place some initial cells, then hit Run button. You may see some amazing animations!

Conclusion

In order to make the game more fun, I also added a Random button and a Clear button to help with placing the cells. The full source code can be found on my GitHub.

Thanks for your reading! If you find this post interesting, please share it with more people by recommending it.