본문 바로가기

React

React(4) - 대화형 틱택토 게임 구현

728x90
반응형

1) React

   1-1) 대화형 틱택토 게임 구현

       1-1-1) 개요

       1-1-2) 게임 완성하기

       1-1-3) 시간 여행 추가하기

       1-1-4) 완성 코드

 

 

 

 

 

 

1) React

1-1) 대화형 틱택토 게임 구현

참조 사이트 : https://ko.reactjs.org/tutorial/tutorial.html

 

자습서: React 시작하기 – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

 

1-1-1) 개요

React

정의 : 사용자 인터페이스를 구축하기 위한 선언적이고 효율적이며 유연한 JavaScript 라이브러리

"React"는 "Component"라고 불리는 작고 고립된 코드의 파편을 이용하여 복잡한 UI를 구성하도록 도움!

여기서 개별 Component의 경우, "props"라는 매개변수를 받아오고 "render 함수"를 통해 표시할 뷰 계층 구조를 반환함!

이때 render 함수는 화면에서 보고자 하는 내용을 반환하는데 React는 설명을 전달받고 결과를 표시함!

특히 render 함수는 rendering할 내용을 경량화한 React element를 반환함!

참고로 다수의 React 개발자는 "JSX"라는 특수한 문법을 사용하여 React의 구조를 보다 쉽게 작성함!

 

 

코드 구성

게임 구현에 사용될 React Component 세 가지

  • Square : <button>을 rendering함!
  • Board : 사각형 9개를 rendering함!
  • Game : 게임판을 rendering하며 나중에 수정할 자리 표시자 값을 가지고 있음!

 

Props를 통해 데이터 전달하기

목적 : Board Component(부모 Component)에서 Square Component(자식 Component)로 데이터(prop)를 전달하기

// Square Component에 value prop을 전달하기 위한 코드
class Board extends React.Component {
  renderSquare(i) {
    return <Square value={i} />;
  }
}

 

// 값을 표시하기 위해 Square Component의 render 함수에 "{this.props.value}" 넣기
class Square extends React.Component {
  render() {
    return (
      <button className="square">
        {this.props.value}
      </button>
    );
  }
}

 

위의 코드 반영 전

 

위의 코드 반영 후(rendering된 결과에서 각 사각형에 숫자가 표시됨)

 

목적 : 사용자와 상호작용하는 Component 만들기 - Square Component를 클릭 시 "X"가 체크되도록 만들기

class Square extends React.Component {
 render() {
   return (
     // this의 혼란스러운 동작을 피하기 위해 이벤트 핸들러에 화살표 함수를 사용함!
     <button className="square" onClick={() => console.log('click')}>
       {this.props.value}
     </button>
   );
 }
}

 

 

※ 주의 : JavaScript class에서 하위 class의 생성자를 정의할 때 항상 super를 호출해야 함. 즉, 모든 React Component class는 생성자를 가질 시 "super(props)" 호출 구문부터 작성해야 함.

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

  render() {
    return (
      <button
        className="square"
        onClick={() => this.setState({value: 'X'})}
      >
        {this.state.value}
      </button>
    );
  }
}

 

"Square Component"의 render 함수 내부에서 onClick 핸들러를 통해 this.setState를 호출하는 것으로 React에게 <button>을 클릭할 시 Square Component가 다시 rendering되어야 한다고 알릴 수 있음. 업데이트 이후에 Square Component의 "this.state.value"는 "X"가 되어 게임판에서 X가 나타나는 것을 확인할 수 있음. 따라서 어떤 Square Component를 클릭하던 X가 나타날 것임.

"Component"에서 "setState"를 호출하면 React는 자동으로 Component 내부의 자식 Component 역시 업데이트함!

 

 

개발자 도구

Chrome과 Firefox의 React Devtools 확장 프로그램을 사용하면 브라우저 개발자 도구에서 React Component 트리를 검사할 수 있음!

React Devtools를 통해 React Component의 props와 state도 확인할 수 있음!

 

 

 

 

1-1-2) 게임 완성하기

지금까지의 코드 작업을 통해 틱택토 게임을 위한 기본 구성 요소를 갖춤. 하지만 완전한 게임을 위해 게임판의 "X"와 "O"를 번갈아 표시할 필요가 있으며 승자를 결정하는 방법이 필요함.

 

State 끌어올리기

현재 게임의 state를 각각의 Square Component에서 유지하고 있다.

이에 승자를 확인하기 위해 9개 사각형의 값을 한 곳에 유지할 것이다.

Board Component가 각 Square Component에 Square의 state를 요청해야 한다고 생각할 수도 있다.

그리고 React에서 이런 접근이 가능하기는 하지만 이 방식은 코드를 이해하기 어렵게 만들고

버그에 취약하며 리팩토링이 어렵기 때문에 추천하지 않는다.

각 Square Component가 아닌 부모 Component(Board)에 게임의 상태를 저장하는 것이 가장 좋은 방법이다.

각 Square Component에 숫자를 넘겨주었을 때와 같이 Board Component는 각 Square Component에게

prop을 전달하는 것으로 무엇을 표시할 지 알려준다.

여러 개의 자식 Component로부터 데이터를 모으거나 두 개의 자식 Component들이 서로 통신하게 하려면

부모 Component에 공유 state를 정의해야 한다.

부모 Component는 props를 사용하여 자식 Component에 state를 다시 전달할 수 있다.

이것은 자식 Component들이 서로 또는 부모 Component와 동기화 하도록 만든다.

state를 부모 Component로 끌어올리는 것은 React Component를 리팩토링할 때 흔히 사용한다.

이번 기회에 시험해 볼 예정이다.

이를 위해 Board Component에 생성자 함수(constructor)를 추가하고,

9개의 사각형에 해당하는 9개의 null 배열을 초기 state로 설정한다.

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
    };
  }

  renderSquare(i) {
    return <Square value={i} />;
  }

 

처음에는 모든 Square Component에서 0부터 8까지 숫자를 보여주기 위해

Board Component에서 value prop을 자식 Component(Square Component)로 전달했다.

또 다른 이전 단계에서는 숫자를 Square Component의 자체 state에 따라 “X” 표시로 바꾸었다.

그렇기 때문에 현재 Square Component는 Board Component에서 전달한 value prop을 무시하고 있다.

이에 따라 prop을 전달하는 방법을 다시 사용할 것이다.

각 Square Component에 현재 값('X', 'O', 또는 null)을 표현하도록 Board Component를 수정할 것이다.

Board Component의 생성자 함수(constructor)에서 squares 배열을 이미 선언했으며

renderSquare 함수를 아래와 같이 수정할 것이다.

renderSquare(i) {
    return <Square value={this.state.squares[i]} />;
}

 

이후 Board Component는 어떤 사각형이 채워졌는지 여부를 관리하므로

Square Component가 Board Component를 변경할 방법이 필요하다.

Component는 자신이 정의한 state에만 접근할 수 있으므로

Square Component에서 Board Component의 state를 직접 변경할 수 없다.

 

대신 Board Component에서 Square Component로 함수를 전달하고,

Square Component는 사각형을 클릭할 시 함수를 호출할 것이다.

이를 위해 Board Component의 renderSquare 함수를 아래와 같이 변경한다.

renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

 

class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
        onClick={() => this.props.onClick()}
      >
        {this.props.value}
      </button>
    );
  }
}

 

Square Component를 클릭하면 Board Component에서 넘겨받은 onClick 함수가 호출된다.

이 때 일어나는 일을 정리하면 아래와 같다.

 

  1. 내장된 DOM <button> Component에 있는 onClick prop은 React에게 click event listener를 설정하라고 알려준다.
  2. 버튼을 클릭하면 React는 Square Component의 render 함수에 정의된 onClick 이벤트 핸들러를 호출한다.
  3. 이벤트 핸들러는 "this.props.onClick()"를 호출하며, Square Component의 "onClick prop"은 Board Component에서 정의되었다.
  4. Board Component에서 Square Component로 "onClick={() => this.handleClick(i)}"를 전달했기 때문에 Square Component를 클릭하면 Board Component의 handleClick(i)를 호출한다.
  5. 아직 handleClick()를 정의하지 않았기 때문에 코드가 깨질 것이며, 지금은 사각형을 클릭하면 “this.handleClick is not a function”과 같은 내용을 표시하는 붉은 에러 화면을 보게될 것이다.

이에 Board Component에 handleClick을 아래와 같이 추가한다.

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
    };
  }

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = 'X';
    this.setState({squares: squares});
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

  render() {
    const status = 'Next player: X';

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

 

 

순서 만들기

플레이어가 수를 둘 때마다 xIsNext(boolean 값)가 뒤집혀

다음 플레이어가 누군지 결정하고 게임의 state가 저장될 것이다.

Board Component의 handleClick 함수를 수정하여 xIsNext 값을 뒤집겠다.

handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

 

// 변경사항을 적용한 Board Component
class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
  }

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

  render() {
    const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

 

 

승자 결정하기

이제 어떤 플레이어가 다음 차례인지 알려주었으니 승부가 나는 때와 더 이상 둘 곳이 없을 때를 알려주어야 한다.

아래 도우미 함수를 복사하여 html 파일 최하단에 붙인다.

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

 

9개의 사각형의 배열을 가지고 함수는 승자를 확인하여 적절한 값으로 'X', 'O', 또는 null을 반환한다.

어떤 플레이어가 우승했는지 확인하기 위해

Board Component의 render 함수에서 calculateWinner(squares)를 호출할 것이다.

한 플레이어가 이긴다면 “Winner: X” 또는 “Winner: O” 같은 문구를 표시할 수 있다.

Board Component의 render 함수에서 선언한 status를 아래 코드로 바꾸면 된다.

render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      // 나머지는 그대로이다.
    )

 

이어서 누군가가 승리하거나 Square Component가 이미 채워졌다면

Board Component의 handleClick 함수가 클릭을 무시하도록 변경하겠다.

handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

 

 

 

 

1-1-3) 시간 여행 추가하기

동작에 대한 기록 저장하기

squares 배열을 직접 변경했다면 시간 여행을 구현하기 어려웠을 것이다.

하지만 slice()를 사용해서 매 동작 이후에 squares 배열의 새로운 복사본을 만들었고,

이를 불변 객체로 취급했다.

이를 통해 과거의 squares 배열의 모든 버전을 저장하고, 이미 지나간 차례를 탐색할 수 있다.

과거의 squares 배열들을 history라는 다른 배열에 저장할 것이다.

history 배열은 첫 동작부터 마지막 동작까지 모든 게임판의 상태를 표현하고 아래와 같은 형태이다.

history = [
  // 첫 동작이 발생하기 전
  {
    squares: [
      null, null, null,
      null, null, null,
      null, null, null,
    ]
  },
  // 첫 동작이 발생한 이후
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, null,
    ]
  },
  // 두 번째 동작이 발생한 이후
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, 'O',
    ]
  },
  // ...
]
 
 
다시 State 끌어올리기
이전 동작에 대한 리스트를 보여주기 위해 최상위 Component(Game Component)가 필요하다.
history를 이용해야 하기 때문에 최상위 Component인 Game Component에 history state를 두겠다.
 
history state를 Game Component에 두었기 때문에
자식 Component(Board)에서 squares state를 더 이상 사용하지 않아도 된다.
Square Component에서 Board Component로 "state를 끌어올렸던 것"처럼
이번에는 Board Component에서 Game Component로 state를 끌어올렸다.
이를 통해 Game Component는 Board Component의 데이터를 완벽히 제어할 수 있으며,
history에 저장된 과거의 차례를 Board Component가 rendering할 수 있게 만든다.
class Board extends React.Component {
  handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

  render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}
 

 

과거의 이동 표시하기

틱택토 게임의 이동 정보를 기록하고 있기 때문에 이제 플레이어에게 과거의 이동을 목록으로 표시할 수 있다.

앞서 React element는 애플리케이션에 전달할 수 있는 클래스형 JavaScript 객체라는 것을 확인했다.

React element 배열을 사용하면 여러 아이템을 rendering할 수 있다.

JavaScript에서 배열은 데이터를 다른 데이터와 함께 매핑할 때 사용하는 map 함수를 가지고 있는데

이를 Game Component의 render 함수에 history 부분에 적용하겠다.

render() {
    const history = this.state.history;
    const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={(i) => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );
  }
 
 
Key 선택하기

단, 배열이나 이터레이터의 자식들은 고유의 "key" prop을 가지고 있어야 한다.

리스트를 rendering할 때 React는 rendering하는 리스트 아이템에 대한 정보를 저장한다.

리스트를 업데이트 할 때 React는 무엇이 변했는지 결정해야 한다.

리스트의 아이템들은 추가, 제거, 재배열, 업데이트될 수 있다.

 

 

시간 여행 구현하기

틱택토 게임의 기록에서 과거의 이동 정보는 이동의 순차적인 숫자를 고유한 ID로 가졌다.

이동은 순서가 바뀌거나 삭제되거나 중간에 삽입될 수 없기 때문에 이동의 인덱스를 키로 사용해도 안전하다.

Game Component의 render 함수 안에서 <li key={move}>로 키를 추가하면 React의 키에 대한 경고가 사라질 것이다.

const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

 

"jumpTo 함수"가 정의되어 있지 않기 때문에 리스트 아이템의 버튼을 클릭하면 에러가 발생한다.

jumpTo를 구현하기 전에 Game Component의 state에 stepNumber를 추가하여 현재 진행 중인 단계를 표시한다.

먼저 Game의 constructor 초기 state로 "stepNumber: 0"을 추가한다.

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [{
        squares: Array(9).fill(null),
      }],
      stepNumber: 0,
      xIsNext: true,
    };
  }

 

다음으로 Game Component의 stepNumber를 업데이트하기 위해 jumpTo를 정의한다.

또한 stepNumber가 짝수일 때 마다 xIsNext를 true로 설정하겠다.

handleClick(i) {
    // 이 함수는 변하지 않는다.
  }

  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2) === 0,
    });
  }

  render() {
    // 이 함수는 변하지 않는다.
  }

 

이제 사각형을 클릭할 때 마다 실행되는 Game Component의 handleClick 함수에 몇 가지 변화를 줄 것이다.

stepNumber state는 현재 사용자에게 표시되는 이동을 반영한다.

새로운 이동을 만든 후에 this.setState의 인자로 "stepNumber: history.length"를 추가하여

stepNumber를 업데이트 해야 한다.

이를 통해 새로운 이동이 생성된 후에 이동이 그대로 남아있는 것을 방지한다.

또한 this.state.history를 this.state.history.slice(0, this.state.stepNumber + 1)로 바꾼다.

이것은 “시간을 되돌려” 그 시점에서 새로운 움직임을 보이면,

지금은 올바르지 않은 “미래”의 기록을 모두 버리는 것을 보장한다.

handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      history: history.concat([{
        squares: squares
      }]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext,
    });
  }
 
 
마지막으로 Game Component의 render 함수를 수정하여 항상 마지막 이동을 rendering하는 대신
stepNumber에 맞는 현재 선택된 이동을 rendering할 것이다.
render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);

    // the rest has not changed

 

 

 

 

1-1-4) 완성 코드

game.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script
      crossorigin
      src="https://unpkg.com/react@18/umd/react.development.js"
    ></script>
    <script
      crossorigin
      src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
    ></script>
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
    <link rel="stylesheet" href="./game.css" />
  </head>
  <body>
    <div
      id="errors"
      style="
        background: #c00;
        color: #fff;
        display: none;
        margin: -20px -20px 20px;
        padding: 20px;
        white-space: pre-wrap;
      "
    ></div>
    <div id="root"></div>

    <script>
      window.addEventListener("mousedown", function (e) {
        document.body.classList.add("mouse-navigation");
        document.body.classList.remove("kbd-navigation");
      });
      window.addEventListener("keydown", function (e) {
        if (e.keyCode === 9) {
          document.body.classList.add("kbd-navigation");
          document.body.classList.remove("mouse-navigation");
        }
      });
      window.addEventListener("click", function (e) {
        if (e.target.tagName === "A" && e.target.getAttribute("href") === "#") {
          e.preventDefault();
        }
      });
      window.onerror = function (message, source, line, col, error) {
        var text = error
          ? error.stack || error
          : message + " (at " + source + ":" + line + ":" + col + ")";
        errors.textContent += text + "\n";
        errors.style.display = "";
      };
      console.error = (function (old) {
        return function error() {
          errors.textContent +=
            Array.prototype.slice.call(arguments).join(" ") + "\n";
          errors.style.display = "";
          old.apply(this, arguments);
        };
      })(console.error);
    </script>
    <script type="text/babel">
      function Square(props) {
        return (
          <button className="square" onClick={props.onClick}>
            {props.value}
          </button>
        );
      }

      class Board extends React.Component {
        renderSquare(i) {
          return (
            <Square
              value={this.props.squares[i]}
              onClick={() => this.props.onClick(i)}
            />
          );
        }

        render() {
          return (
            <div>
              <div className="board-row">
                {this.renderSquare(0)}
                {this.renderSquare(1)}
                {this.renderSquare(2)}
              </div>
              <div className="board-row">
                {this.renderSquare(3)}
                {this.renderSquare(4)}
                {this.renderSquare(5)}
              </div>
              <div className="board-row">
                {this.renderSquare(6)}
                {this.renderSquare(7)}
                {this.renderSquare(8)}
              </div>
            </div>
          );
        }
      }

      class Game extends React.Component {
        constructor(props) {
          super(props);
          this.state = {
            history: [
              {
                squares: Array(9).fill(null),
              },
            ],
            stepNumber: 0,
            xIsNext: true,
          };
        }

        handleClick(i) {
          const history = this.state.history.slice(
            0,
            this.state.stepNumber + 1
          );
          const current = history[history.length - 1];
          const squares = current.squares.slice();
          if (calculateWinner(squares) || squares[i]) {
            return;
          }
          squares[i] = this.state.xIsNext ? "X" : "O";
          this.setState({
            history: history.concat([
              {
                squares: squares,
              },
            ]),
            stepNumber: history.length,
            xIsNext: !this.state.xIsNext,
          });
        }

        jumpTo(step) {
          this.setState({
            stepNumber: step,
            xIsNext: step % 2 === 0,
          });
        }

        render() {
          const history = this.state.history;
          const current = history[this.state.stepNumber];
          const winner = calculateWinner(current.squares);

          const moves = history.map((step, move) => {
            const desc = move ? "Go to move #" + move : "Go to game start";
            return (
              <li key={move}>
                <button onClick={() => this.jumpTo(move)}>{desc}</button>
              </li>
            );
          });

          let status;
          if (winner) {
            status = "Winner: " + winner;
          } else {
            status = "Next player: " + (this.state.xIsNext ? "X" : "O");
          }

          return (
            <div className="game">
              <div className="game-board">
                <Board
                  squares={current.squares}
                  onClick={(i) => this.handleClick(i)}
                />
              </div>
              <div className="game-info">
                <div>{status}</div>
                <ol>{moves}</ol>
              </div>
            </div>
          );
        }
      }

      // ========================================

      const root = ReactDOM.createRoot(document.getElementById("root"));
      root.render(<Game />);

      function calculateWinner(squares) {
        const lines = [
          [0, 1, 2],
          [3, 4, 5],
          [6, 7, 8],
          [0, 3, 6],
          [1, 4, 7],
          [2, 5, 8],
          [0, 4, 8],
          [2, 4, 6],
        ];
        for (let i = 0; i < lines.length; i++) {
          const [a, b, c] = lines[i];
          if (
            squares[a] &&
            squares[a] === squares[b] &&
            squares[a] === squares[c]
          ) {
            return squares[a];
          }
        }
        return null;
      }
    </script>
  </body>
</html>

 

 

game.css

body {
  font: 14px "Century Gothic", Futura, sans-serif;
  margin: 20px;
}

ol,
ul {
  padding-left: 30px;
}

.board-row:after {
  clear: both;
  content: "";
  display: table;
}

.status {
  margin-bottom: 10px;
}

.square {
  background: #fff;
  border: 1px solid #999;
  float: left;
  font-size: 24px;
  font-weight: bold;
  line-height: 34px;
  height: 34px;
  margin-right: -1px;
  margin-top: -1px;
  padding: 0;
  text-align: center;
  width: 34px;
}

.square:focus {
  outline: none;
}

.kbd-navigation .square:focus {
  background: #ddd;
}

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}