Sådan koder du Game of Life med React

Livets spil involverer et todimensionalt ortogonalt gitter af firkantede celler, som hver er i en af ​​to mulige tilstande, levende eller døde. Ved hvert trin interagerer hver celle med sine otte tilstødende naboer ved at følge et simpelt sæt regler, der resulterer i fødsler og dødsfald.

Det er et nul-spiller spil. Dens udvikling bestemmes af dets oprindelige tilstand og kræver ikke yderligere input fra spillerne. Man interagerer med spillet ved at oprette en indledende konfiguration og observere, hvordan det udvikler sig, eller for avancerede spillere ved at skabe mønstre med bestemte egenskaber.

Regler

  1. Enhver levende celle med færre end to levende naboer dør, som ved underbefolkning
  2. Enhver levende celle med to eller tre levende naboer lever videre til den næste generation
  3. Enhver levende celle med mere end tre levende naboer dør, som ved overbefolkning
  4. Enhver død celle med nøjagtigt tre levende naboer bliver en levende celle, som ved reproduktion

Selvom spillet kan kodes perfekt med vanille JavaScript, var jeg glad for at gennemgå udfordringen med React. Så lad os starte.

Opsætning af React

Der er flere måder at konfigurere React på, men hvis du er ny på det, anbefaler jeg at tjekke Opret React App- dokumenter og github samt den detaljerede React-oversigt af Tania Rascia.

Design af spillet

Hovedbilledet øverst er min implementering af spillet. Brættet, der indeholder lys (levende) og mørke (døde) celler, viser spillets udvikling. Controllerne giver dig mulighed for at starte / stoppe, gå et trin ad gangen, oprette et nyt kort eller rydde det for at eksperimentere med dine egne mønstre ved at klikke på de enkelte celler. Skyderen styrer hastigheden, og generationen informerer antallet af afsluttede gentagelser.

Ud over den hovedkomponent, der holder staten, opretter jeg separat en funktion til at generere alle kortets cellestatus fra bunden, en komponent til kortgitteret og en anden til skyderen.

Opsætning af App.js

Lad os først importere React og React.Component fra “react”. Angiv derefter, hvor mange rækker og kolonner bordgitteret har. Jeg går med 40 med 60, men jeg er velkommen til at lege med forskellige numre. Derefter kommer de separate funktions- og funktionskomponenter (bemærk det store bogstav med store bogstaver) beskrevet ovenfor såvel som klassekomponenten, der indeholder tilstanden og metoderne, inklusive gengivelsen. Lad os endelig eksportere hovedkomponent-appen.

import React, { Component } from 'react'; const totalBoardRows = 40; const totalBoardColumns = 60; const newBoardStatus = () => {}; const BoardGrid = () => {}; const Slider = () => {}; class App extends Component { state = {}; // Methods ... render() { return ( ); } } export default App;

Genererer et nyt tavls cellestatus

Da vi har brug for at kende status for hver celle og dens 8 naboer for hver iteration, lad os oprette en funktion, der returnerer en række arrays, der hver indeholder celler med boolske værdier. Antallet af arrays i hovedarrayet svarer til antallet af rækker, og antallet af værdier inden for hver af disse arrays svarer til antallet af kolonner. Så hver booleske værdi repræsenterer tilstanden for hver celle, "levende" eller "død". Funktionens parameter er som standard mindre end 30% chance for at være i live, men fik frihed til at eksperimentere med andre tal.

const newBoardStatus = (cellStatus = () => Math.random()  { const grid = []; for (let r = 0; r < totalBoardRows; r++) { grid[r] = []; for (let c = 0; c < totalBoardColumns; c++) { grid[r][c] = cellStatus(); } } return grid; }; /* Returns an array of arrays, each containing booleans values (40) [Array(60), Array(60), ... ] 0: (60) [true, false, true, ... ] 1: (60) [false, false, false, ... ] 2: (60) [false, false, true, ...] ... */

Generering af tavlen

Lad os definere en funktionskomponent, der opretter kortgitteret og tildeler det til en variabel. Funktionen modtager status for hele tavlestatus og en metode, der giver brugerne mulighed for at skifte status for individuelle celler som rekvisitter. Denne metode er defineret på hovedkomponenten, hvor hele applikationens tilstand opbevares.

Hver celle er repræsenteret af en tabel og har en className-attribut, hvis værdi afhænger af den boolske værdi af den tilsvarende tavlecelle. Den spiller, der klikker på en celle, resulterer i, at metoden sendes som rekvisitter, der kaldes med cellens række- og kolonneplacering som argument.

Tjek Lifting State Up for yderligere information om videregivelsesmetoder som rekvisitter, og glem ikke at tilføje tasterne.

const BoardGrid = ({ boardStatus, onToggleCellStatus }) => { const handleClick = (r,c) => onToggleCellStatus(r,c); const tr = []; for (let r = 0; r < totalBoardRows; r++) { const td = []; for (let c = 0; c < totalBoardColumns; c++) { td.push(  handleClick(r,c)} /> ); } tr.push({td}); } return 
    
      {tr}
     
; };

Oprettelse af hastighedsskyderen

Denne funktionskomponent opretter en skyder, der giver spillerne mulighed for at ændre iterationshastigheden. Den modtager tilstanden for den aktuelle hastighed og en metode til at håndtere hastighedsændringen som rekvisitter. Du kan prøve forskellige minimums-, maksimum- og trinværdier. En hastighedsændring resulterer i, at metoden, der sendes som rekvisitter, kaldes med den ønskede hastighed som argument.

const Slider = ({ speed, onSpeedChange }) => { const handleChange = e => onSpeedChange(e.target.value); return (  ); };

Hovedkomponent

Da det indeholder applikationens tilstand, lad os gøre det til en klassekomponent. Bemærk, at jeg ikke bruger Hooks, en ny tilføjelse i React 16.8, der giver dig mulighed for at bruge state og andre React-funktioner uden at skrive en klasse. Jeg foretrækker at bruge de eksperimentelle syntakser for offentlige klassefelter, så jeg binder ikke metoderne inden for konstruktøren.

Lad os dissekere det.

Stat

Jeg definerer tilstanden som et objekt med egenskaberne for tavlestatus, antal generationer, spil, der kører eller stoppes, og gentagelseshastigheden. Når spillet starter, vil status for tavlens celler være den, der returneres af opkaldet til den funktion, der genererer en ny tavlestatus. Generation starter ved 0, og spillet kører kun, når brugeren beslutter. Standardhastigheden er 500 ms.

class App extends Component { state = { boardStatus: newBoardStatus(), generation: 0, isGameRunning: false, speed: 500 }; // Other methods ... }

Kør / stop-knappen

Funktion, der returnerer et andet knapelement afhængigt af spiltilstanden: kører eller stoppes.

class App extends Component { state = {...}; runStopButton = () => { return this.state.isGameRunning ? Stop : Start; } // Other methods ... }

Klart og nyt bord

Methods to handle players request to start with a new random board’s cell status or to clear the board completely so they can then experiment by toggling individual cell status. The difference between them is that the one that clears the board sets the state for all cells to false, while the other doesn’t pass any arguments to the newBoardStatus method so the status of each cell becomes by default a random boolean value.

class App extends Component { state = {...}; runStopButton = () => {...} handleClearBoard = () => { this.setState({ boardStatus: newBoardStatus(() => false), generation: 0 }); } handleNewBoard = () => { this.setState({ boardStatus: newBoardStatus(), generation: 0 }); } // More methods ... }

Toggle cell status

We need a method to handle players’ requests to toggle individual cell status, which is useful to experiment with custom patterns directly on the board. The BoardGrid component calls it every time the player clicks on a cell. It sets the states of the board status by calling a function and passing it the previous state as argument.

The function deep clones the previous board’s status to avoid modifying it by reference when updating an individual cell on the next line. (Using const clonedBoardStatus = […boardStatus] would modify the original status because Spread syntax effectively goes one level deep while copying an array, therefore, it may be unsuitable for copying multidimensional arrays. Note that JSON.parse(JSON.stringify(obj)) doesn’t work if the cloned object uses functions). The function finally returns the updated cloned board status, effectively updating the status of the board.

For deep cloning check out here, here and here.

class App extends Component { state = {...}; runStopButton = () => {...} handleClearBoard = () => {...} handleNewBoard = () => {...} handleToggleCellStatus = (r,c) => { const toggleBoardStatus = prevState => { const clonedBoardStatus = JSON.parse(JSON.stringify(prevState.boardStatus)); clonedBoardStatus[r][c] = !clonedBoardStatus[r][c]; return clonedBoardStatus; }; this.setState(prevState => ({ boardStatus: toggleBoardStatus(prevState) })); } // Other methods ... }

Generating the next step

Here is where the next game iteration is generated by setting the state of the board status to the returned value of a function. It also adds one to the generation’s state to inform the player how many iterations have been produced so far.

The function (“nextStep”) defines two variables: the board status and a deep cloned board status. Then a function calculates the amount of neighbors (within the board) with value true for an individual cell, whenever it is called. Due to the rules, there’s no need to count more than four true neighbors per cell. Lastly, and according to the rules, it updates the cloned board’s individual cell status and return the cloned board status, which is used in the setState.

class App extends Component { state = {...}; runStopButton = () => {...} handleClearBoard = () => {...} handleNewBoard = () => {...} handleToggleCellStatus = () => {...} handleStep = () => { const nextStep = prevState => { const boardStatus = prevState.boardStatus; const clonedBoardStatus = JSON.parse(JSON.stringify(boardStatus)); const amountTrueNeighbors = (r,c) => { const neighbors = [[-1, -1], [-1, 0], [-1, 1], [0, 1], [1, 1], [1, 0], [1, -1], [0, -1]]; return neighbors.reduce((trueNeighbors, neighbor) => { const x = r + neighbor[0]; const y = c + neighbor[1]; const isNeighborOnBoard = (x >= 0 && x = 0 && y < totalBoardColumns); /* No need to count more than 4 alive neighbors */ if (trueNeighbors < 4 && isNeighborOnBoard && boardStatus[x][y]) { return trueNeighbors + 1; } else { return trueNeighbors; } }, 0); }; for (let r = 0; r < totalBoardRows; r++) { for (let c = 0; c < totalBoardColumns; c++) { const totalTrueNeighbors = amountTrueNeighbors(r,c); if (!boardStatus[r][c]) { if (totalTrueNeighbors === 3) clonedBoardStatus[r][c] = true; } else { if (totalTrueNeighbors  3) clonedBoardStatus[r][c] = false; } } } return clonedBoardStatus; }; this.setState(prevState => ({ boardStatus: nextStep(prevState), generation: prevState.generation + 1 })); } // Other methods ... } 

Handling the speed change and the start/stop action

These 3 methods only set the state value for the speed and isGameRunning properties.

Then, within the componentDidUpdate Lifecycle method, let’s clear and/or set a timer depending on different combinations of values. The timer schedules a call to the handleStep method at the specified speed intervals.

class App extends Component { state = {...}; runStopButton = () => {...} handleClearBoard = () => {...} handleNewBoard = () => {...} handleToggleCellStatus = () => {...} handleStep = () => {...} handleSpeedChange = newSpeed => { this.setState({ speed: newSpeed }); } handleRun = () => { this.setState({ isGameRunning: true }); } handleStop = () => { this.setState({ isGameRunning: false }); } componentDidUpdate(prevProps, prevState) { const { isGameRunning, speed } = this.state; const speedChanged = prevState.speed !== speed; const gameStarted = !prevState.isGameRunning && isGameRunning; const gameStopped = prevState.isGameRunning && !isGameRunning; if ((isGameRunning && speedChanged) || gameStopped) { clearInterval(this.timerID); } if ((isGameRunning && speedChanged) || gameStarted) { this.timerID = setInterval(() => { this.handleStep(); }, speed); } } // Render method ... }

The render method

The last method within the App component returns the desired structure and information of the page to be displayed. Since the state belongs to the App component, we pass the state and methods to the components that need them as props.

class App extends Component { // All previous methods ... render() { const { boardStatus, isGameRunning, generation, speed } = this.state; return ( 

Game of Life

Exporting the default App

Lastly, let’s export the default App (export default App;), which is imported along with the styles from “index.scss” by “index.js”, and then rendered to the DOM.

And that’s it! ?

Check out the full code on github and play the game here. Try these patterns below or create your own for fun.

Thanks for reading.