Gomoku with React


Posted by Christy on 2022-03-25

It's the whole process of how I made a Gomoku game with react. 中文版

0. Description

Gomoku also called Five in a Row, is an abstract strategy board game. Players alternate turns to place a piece of their colour on an empty intersection. Black plays first. The winner is the first player to form an unbroken chain of five stones horizontally, vertically, or diagonally.

So, what I am trying to do here is a Gomoku game like the below:

There're two important parts of this game:

a. UI: Make a 19 x 19 board, and while clicking the board shows a black or white piece alternatively

It supposed to be cell -> row -> board

a.1 let's do a cell first

a.2 try to render the 19 cells in a row

a.3 try to render the 19 rows in a board

a.4 while clicking the board, it shows black or white pieces alternatively

b. Logic: How do I track the winner?

b.1 draw

b.2 winner with four possibilities:

  • five pieces with same color in a row

  • five pieces with same color in a column

  • five pieces with same color in a right diagonal line

  • five pieces with same color in a left diagonal line

1. Recap sequence

At first, I tried to render a square and then 19 x 19 rows and columns, but I found it was not a good way to do so. The first thing I get stuck on is how to render the board. The logic in React is different from what I thought before.

The tiniest component here is a cell with lines made by CSS pseudo-elements and remove the extra line on all four edges with props.

Array(19).fill(Array(19).fill(null)) to render 19 x 19 board, I need to be familiar with the standard built-in objects in JavaScript, and useState for the board initialization.

The structure will be the board containing 19 rows, a row containing 19 squares.

2. Set up environment

a. set up environment

create a file -> arrange files -> install styled-components & Prettier ESLint

a.1 go to file Gomoku and enter below three commands

$ npx create-react-app Gomoku
$ cd Gomoku
$ npm start

a.2 go to index.js and remove strict mode

ps don't forget to add a comma after <APP />,

a.3 go to App.js and remove everything inside <div className="App">remove here</div>

a.4 install styled-components & Prettier ESLint(install prettier & ESlint if needed)

$npm install --save styled-components

$npm install --save prettier eslint prettier-eslint: install prettier, ESlint and Prettier ESLint three plugins

note: reopen VsCode is a good way to solve some problems, sometimes it takes time to wait for Prettier ESLint running.

b. Here we go: npm start

See Tutorial: Intro to React, this is for class component, what I gonna do is try to imitate and do it in function component.

3. Practice — Render a board

How on earth to render a Gomoku board?

a. Draw a square

b. Add black outlines and try to render

function App() {
  const [board, setBoard] = useState([""]);

  return (
    <div className="App">
      {board.map((board) => {
        return <Square />;
      })}
    </div>
  );
}

c. Think about the structure

  • Board
    • Row
      • Square
export default function App() {
  const [board, setBoard] = useState("");

  return (
    <div className="App">
      <Board>
        <Row>
          <Square />
        </Row>
      </Board>
    </div>
  );
}

d. Make a board with Array(19).fill(Array(19).fill(null))

e. Make the grids with &:before and &:after

// Square.js code
// this is the tiniest component export as a function component

const Cell = styled.div`
  background: #ba8c63;
  height: 30px;
  width: 30px;
  position: relative;

  &:before {
    content: "";
    height: 100%;
    width: 2px;
    background: black;
    position: absolute;
  }

  &:after {
    content: "";
    width: 100%;
    height: 2px;
    background: black;
    position: absolute;
  }
`;
// App.js code
export default function App() {
  const [board, setBoard] = useState(Array(19).fill(Array(19).fill(null)));

  return (
    <div className="App">
      <Wrapper>
        <Board>
          {board.map((row) => {
            return (
              <Row>
                {row.map(() => {
                  return <Square />;
                })}
              </Row>
            );
          })}
        </Board>
      </Wrapper>
    </div>
  );
}

f. Shifting the lines

// Square.js code

import styled from "styled-components";

const Cell = styled.div`
  background: #ba8c63;
  height: 48px;
  width: 48px;
  position: relative;

  &:before {
    content: "";
    height: 100%;
    width: 2px;
    background: black;
    position: absolute;
    top: 0%;
    left: 50%;
    transform: translateX(-50%);
  }

  &:after {
    content: "";
    width: 100%;
    height: 2px;
    background: black;
    position: absolute;
    top: 50%;
    left: 0;
    transform: translateY(-50%);
  }
`;

function Square() {
  return (
    <Cell>
      <Piece />
    </Cell>
  );
}

export default Square;
export default function App() {
  const [board, setBoard] = useState(Array(19).fill(Array(19).fill(null)));

  return (
    <div className="App">
      <Board>
        {board.map((row) => {
          return (
            <Row>
              {row.map(() => {
                return <Square />;
              })}
            </Row>
          );
        })}
      </Board>
    </div>
  );
}

f.1 Reference: Transform: translate(-50%, -50%)

The reason why transform: translate(-50%, -50%) is required is because you want the center of the element to line up with the center of its parent. In simple terms, it can be boiled down to translateX(-50%) translateY(-50%), which means:

move me leftwards by 50% of my width, along the x-axis, and
move me upwards by 50% of my height, along the y-axis

g. Remove the extra lines near boarder

In Square.js, Cell component must have parameters (row, rowIndex, col, colIndex) and pass them to the component in App.js

// Square.js code

import styled from "styled-components";
import React from "react";

const Cell = styled.div`
  background: #ba8c63;
  width: 30px;
  height: 30px;
  position: relative;

  &:before {
    content: "";
    height: 100%;
    width: 2px;
    background: black;
    position: absolute;
    top: 0;
    left: 50%;
    transform: translateX(-50%);

    ${(props) =>
      props.$row === 0 &&
      `
      top: 50%;
    `}

    ${(props) =>
      props.$row === 18 &&
      `
      height: 50%;
    `}
  }

  &:after {
    content: "";
    width: 100%;
    height: 2px;
    background: black;
    position: absolute;
    top: 50%;
    left: 0;
    transform: translateY(-50%);

    ${(props) =>
      props.$col === 0 &&
      `
      left: 50%;
    `}

    ${(props) =>
      props.$col === 18 &&
      `
      width: 50%;
    `}
  }
`;

const Square = ({ row, col }) => {
  return (
    <Cell $row={row} $col={col}>
      <Piece />
    </Cell>
  );
};

export default Square;
// App.js code
import React, { useState } from "react";
import Square from "./Components/Square";
import styled from "styled-components";

const Wrapper = styled.div`
  display: flex;
  text-align: center;
  margin: 100px auto;
`;

const Board = styled.div`
  margin: 0 auto;
`;

const Row = styled.div`
  display: flex;
`;

export default function App() {
  const [board, setBoard] = useState(Array(19).fill(Array(19).fill(null)));

  return (
    <div className="App">
      <Wrapper>
        <Board>
          {board.map((row, rowIndex) => {
            return (
              <Row>
                {row.map((col, colIndex) => {
                  return <Square row={rowIndex} col={colIndex} />;
                })}
              </Row>
            );
          })}
        </Board>
      </Wrapper>
    </div>
  );
}

4. Practice — Render black or white piece while clicking the board

1. Piece is a circle with color

const Piece = styled.div`
  width: 100%;
  height: 100%;
  border-radius: 50%;
  position: absolute;
  background: white;
  transform: scale(0.5);
  z-index: 1;
`;

2. Gotcha, the grids

a. I need three information while clicking the square component

The row, the column and save the position in two-dimensional array

Two-dimensional array will start from 0, yes, it's the beauty of being a software engineer that we count from 0.

b. The code in square

// Square code

const Square = ({ row, col, value, onClick }) => {
  const handleSquareClick = () => {
    onClick(row, col, value);
  };
  return (
    <Cell $row={row} $col={col} onClick={handleSquareClick}>
      <Piece />
    </Cell>
  );
};

c. Don't change the board directly. Instead, copy and then change the state

It's similar with the done/undone function in todo list, if it's not the clicked one then return, if yes then change the state.

// App.js code

const updateBoard = (y, x, newValue) => {
    setBoard((board) =>
      board.map((row, currentY) => {
        if (currentY !== y) return row;
        return row.map((col, currentX) => {
          if (currentX !== x) return col;
          return newValue;
        });
      })
    );
  };

3. Black goes first, white is next

a. Show the black or white pieces

const Piece = styled.div`
  width: 100%;
  height: 100%;
  border-radius: 50%;
  position: absolute;
  transform: scale(0.5);
  z-index: 1;
  cursor: pointer;

  ${(props) =>
    props.$value === "black" &&
    `
    background: black;
  `}

  ${(props) =>
    props.$value === "white" &&
    `
    background: white;
  `}
`;

const Square = ({ row, col, value, onClick }) => {
  const handleSquareClick = () => {
    onClick(row, col, value);
  };
  return (
    <Cell $row={row} $col={col} onClick={handleSquareClick}>
      <Piece $value={value} />
    </Cell>
  );
};

b. First is black, second is white

export default function App() {
  const [board, setBoard] = useState(Array(19).fill(Array(19).fill(null)));

  const updateBoard = (y, x, newValue) => {
    setBoard((board) =>
      board.map((row, currentY) => {
        if (currentY !== y) return row;

        return row.map((col, currentX) => {
          if (currentX !== x) return col;
          return newValue;
        });
      })
    );
  };

  const isBlackNext = useRef(true);
  const lastRow = useRef();
  const lastCol = useRef();

  const handlePieceClick = (row, col, value) => {
    if (value) return;

    lastRow.current = row; 
    lastCol.current = col;
    updateBoard(row, col, isBlackNext.current ? "black" : "white");
    isBlackNext.current = !isBlackNext.current;
  };

  return (
    <div className="App">
      <Wrapper>
        <Board>
          {board.map((row, rowIndex) => {
            return (
              <Row key={rowIndex}>
                {row.map((col, colIndex) => {
                  return (
                    <Square
                      key={colIndex}
                      value={board[rowIndex][colIndex]}
                      row={rowIndex}
                      col={colIndex}
                      onClick={handlePieceClick}
                    />
                  );
                })}
              </Row>
            );
          })}
        </Board>
      </Wrapper>
    </div>
  );
}

5. Practice — The Winner goes to…

The same color with five pieces will win

a. Locate four directions from one spot to count the numbers of pieces

a.1 [directionY, directionX] locates the position

do...while

function countTotal(board, currentY, currentX, directionY, directionX) {
  const now = board[currentY][currentX];
  let tempX = currentX;
  let tempY = currentY;
  let total = 0;

  do {
    tempX += directionX;
    tempY += directionY;

    if (tempY === -1 || tempY === 19 || tempX === -1 || tempX === 19) break;

    if (board[tempY][tempX] === now) {
      total++;
    } else {
      break;
    }
  } while (true);

  return total;
}

a.2 Get color by board[y][x]

There're three data which saved in a piece: x, y and color

a.3 The edge line isn't counted, the problem is in the countTotal function

Problems show in [0, 0]...[0, 18] or [18, 0]...[1, 0]

useEffect(() => {
  if (!lastRow.current || !lastCol.current) return;
  setWinner(findWinner(board, lastRow.current, lastCol.current));
  console.log(lastRow.current, lastCol.current);
}, [board]);

Debug with console.log and found this line if (!lastRow.current || !lastCol.current) return;

It supposed to be if (lastRow.current === undefined || lastCol.current === undefined) return;

b. The draw condition

b.1 Check every rows and columns, if there's piece then it's a draw

if (board.every((row) => row.every((col) => col))) {
  return "draw";
}

Array.prototype.every()

b.2 show the winner

Found the winner with these two functions countTotal, findWinner

Make a hook and pass the value to App.js

6. RWD

// App.js

import styled from "styled-components";
import Square from "./Components/Square";
import useBoard from "./useBoard.js";

const Container = styled.div`
  font-family: "open sans";
  font-weight: 300;
  min-width: 600px;
`;

const Info = styled.div`
  text-align: center;
`;

const Title = styled.div`
  font-size: 36px;
`;

const Message = styled.div`
  font-size: 24px;
`;

const Button = styled.button`
  font-size: 18px;
  font-family: "open sans";
  cursor: pointer;
  margin: 20px auto;
  padding: 8px;
  border-color: silver;
  border-radius: 5px;
  background: silver;
  color: #555;
`;

const Wrapper = styled.div`
  display: flex;
`;

const Board = styled.div`
  margin: 0 auto;
`;

const Row = styled.div`
  display: flex;
`;

export default function App() {
  const { board, winner, handlePieceClick, playAgain } = useBoard();

  return (
    <Container>
      <Info>
        <Title>Gomoku</Title>
        {winner && <Message>The Winner is: {winner}!</Message>}
        <Button onClick={playAgain}>Play again</Button>
      </Info>
      <Wrapper>
        <Board>
          {board.map((row, rowIndex) => {
            return (
              <Row key={rowIndex}>
                {row.map((col, colIndex) => {
                  return (
                    <Square
                      key={colIndex}
                      value={board[rowIndex][colIndex]}
                      row={rowIndex}
                      col={colIndex}
                      onClick={handlePieceClick}
                    />
                  );
                })}
              </Row>
            );
          })}
        </Board>
      </Wrapper>
    </Container>
  );
}

Reference:

五子棋 Gobang

Sample










Related Posts

JAVA - 物件導向入門

JAVA - 物件導向入門

將陣列裡面物件的一樣屬性整理一起

將陣列裡面物件的一樣屬性整理一起

來幫你的程式寫測試吧!

來幫你的程式寫測試吧!


Comments