thumbnail
React로 Tic Tac Toe 게임 만들기
Jan 09, 2023

velog에 올렸던 내용 복기

Tic Tac Toe

Tic-Tac-Toe(틱택토)란 두 명이 3✕3 크기의 판에 번갈아가며 O와 X를 그려 가로, 세로 혹은 대각선 상에 놓이도록 하는 놀이이다. 오목과 비슷한 게임이라고 할 수 있다. React 공식문서에서 제공하는 튜토리얼을 참고해 리액트 기본 문법을 익힐 겸 만들어보려 한다.


기본 세팅

기본 틀

https://codepen.io/gaearon/pen/oWWQNa?editors=0010

위 사이트 코드를 참고해 기본 세팅을 해준다. css는 너무 길어서 본 글에서 모두 생략한다.


먼저 react로 새 프로젝트 폴더를 생성한다. react 프로젝트 생성 방법은 아래 글에 설명되어 있다.

3✕3칸 생성

index.html는 아래와 같이 세팅한다. css는 필요한 부분만 골라 <style> 태그 안에 넣었다.

<head> <style> body { margin: 20px; font: 14px "Century Gothic", Futura, sans-serif; } ol, ul { padding-left: 30px; } </style> </head> <body> <div id="root"></div> </body>

src에 index.js를 제외한 다른 파일들은 삭제한 후, 아래 파일들을 생성한다.

src
├─ Game.js
├─ Board.js
├─ Square.js


Square.js

Square 컴포넌트는 틱택토 하나의 칸에 해당하며 <button>을 렌더링한다.

class Square extends React.Component { render() { return ( <OneSquare /> // button ); } } export default Square;

Board.js

Board 컴포넌트는 전체 9개의 칸을 나타낸다. Square 컴포넌트를 불러와 3개씩 묶어 3개의 열을 생성한다.

class Board extends React.Component { renderSquare(i) { return <Square />; } render() { const status = 'Next player: X'; return ( <div> <Status>{status}</Status> <BoardRow> {this.renderSquare(0)} {this.renderSquare(1)} {this.renderSquare(2)} </BoardRow> <BoardRow> {this.renderSquare(3)} {this.renderSquare(4)} {this.renderSquare(5)} </BoardRow> <BoardRow> {this.renderSquare(6)} {this.renderSquare(7)} {this.renderSquare(8)} </BoardRow> </div> ); } } export default Board;

Game.js

Game 컴포넌트는 게임 전체 판을 나타낸다. 아직은 사용자와 상호작용하지 않지만 일단 다음과 같이 생성해준다.

class Game extends React.Component { render() { return ( <GameWrapper> <GameBoard> <Board /> </GameBoard> <GameInfo> <div>{/* status */}</div> <ol>{/* TODO */}</ol> </GameInfo> </GameWrapper> ); } } export default Game;

마지막으로 index.jsGame 컴포넌트를 불러와 코드를 수정하면 기본 세팅이 완료된다.

ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <Game /> </React.StrictMode>, );

yarn start로 로컬 창을 띄워보면 아래와 같이 기본 틱택토 게임판이 생성된 것을 확인할 수 있다.

Tic Tac Toe base

구성 요소 추가

Props로 데이터 전달

props는 properties의 줄임말로 데이터를 컴포넌트에 전달할 때 사용한다. props를 이용해 게임판에 O 또는 X 표시를 띄워보자.

Square 컴포넌트에 value라는 Props를 전달하려고 한다. 부모인 Board 컴포넌트의 renderSquare 함수 내부에 아래 코드를 추가한다.

class Board extends React.Component { renderSquare(i) { return <Square value={i} />; } // ... }

i에 해당하는 숫자가 value props에 담겨 Square로 전달되는 것이다.

props는 객체 형태로 전달되며, 파라미터를 통해 조회할 수 있다. Square 컴포넌트에서 value 값을 사용하기 위해 this.props.value를 추가한다.

class Square extends React.Component { render() { return ( <OneSquare> {this.props.value} </OneSquare> ); } }

변경 후 렌더링하면 각각의 칸에 숫자가 표시된다.

Tic Tac Toe props

유저와의 상호작용

칸을 클릭하면 콘솔에 메시지가 출력되도록 Square의 button 컴포넌트에 onClick 이벤트를 추가한다.

class Square extends React.Component { render() { return ( <OneSquare onClick={() => {console.log('click')}}> {this.props.value} </OneSquare> ); } }

onClick={() => console.log('click')}은 onClick이라는 props로 함수를 전달한다.
❌ () => 를 빼고 onClick{console.log(‘click’)}처럼 작성하지 않도록 주의해야 한다. ❌

칸을 클릭하면 ‘click’이라는 메시지가 콘솔 창에 출력된다.

Click Square


이제 콘솔 창 말고 화면에서 상호작용을 나타내보자. 이때 동적인 값 state를 사용한다.

먼저 Square 컴포넌트 안에 초기this.state를 지정하는 생성자를 추가한다. 처음에는 value 값이 비공개여야 하므로 null로 설정한다. 그리고 onClick 핸들러를 아래와 같이 수정한 후 <OneSquare> 태그 내부를 this.state.value로 변경한다.

onClick 핸들러를 통해 this.setState를 호출하는 것으로 버튼을 클릭할 때 Square가 다시 렌더링 되어야 한다고 알리는 것이다. 리액트는 컴포넌트에서 setState를 호출하면 자동으로 내부 자식 컴포넌트까지 업데이트한다.

class Square extends React.Component { constructor(props) { super(props); this.state = { value: null, }; } render() { return ( <OneSquare onClick={() => this.setState({value: 'X'})}> {this.state.value} </OneSquare> ); } }

칸을 클릭하면 X 표시가 나타나는 것을 확인할 수 있다.

X mark

상태 값 관리

승패를 결정하기 위해 9개 칸의 상태 값을 한 곳에 유지하려 한다. Board 컴포넌트에서 각 Square에게 props를 전달하는 것으로 ‘X’와 ‘O’ 증 무엇을 표시할 것인지 알린다.

여러 개의 자식으로부터 데이터를 모으거나 두 개의 자식 컴포넌트들이 서로 통신하게 하려면 부모 컴포넌트에 공유 state를 정의해야 한다. 부모 컴포넌트는 props를 사용하여 자식 컴포넌트에 state를 다시 전달할 수 있다.

Board 컴포넌트에 생성자를 추가하고 초기 state를 9개의 칸에 해당하는 null 배열로 설정한다. 그리고 각 Square가 현재 값(‘X’,‘O’ 또는 null)을 표현하도록 renderSquare 내부의 value를 수정하고 onClick 이벤트를 추가한다.

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

Square 컴포넌트는 아래와 같이 수정한다. 생성자도 이제 필요없으니 삭제한다.

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

화면상으로는 이전과 다를 게 없어 보이지만, 이제는 state가 각 Square 컴포넌트 대신 Board 컴포넌트에 저장된다는 것이다. Board 컴포넌트의 모든 사각형 상태를 유지하는 것으로 이후에 승자를 결정하는 것이 가능하게 되었다.

함수 컴포넌트로 변경

클래스형으로 작성된 컴포넌트를 함수 컴포넌트로 변경하자. 함수형은 클래스형보다 간단하게 컴포넌트를 작성할 수 있고 메모리 자원을 덜 사용한다는 장점이 있다.

컴포넌트 명 앞에 function을 붙여주어 간결하게 작성하고 props 내부 값 조회도 비구조화 할당 문법을 사용해 바꿔준다.


Square.js

function Square({onClick, value}) { return ( <OneSquare onClick={onClick}> {value} </OneSquare> ); }

Board.js

함수형 컴포넌트에서는 Hooks 기능 중 하나인 useState를 사용해 상태를 관리해야 한다. 먼저, 코드 상단에 import로 useState를 불러오고 상태의 기본값을 파라미터로 넣어 호출해준다. 첫 번째 요소는 현재 state, 두 번째 요소는 state를 변경시키는 함수이다. 소괄호 안에는 초기 state 값을 넣어주면 된다.

import React, { useState } from 'react'; // ... function Board() { const [state, setState] = useState({ squares: Array(9).fill(null) }); const handleClick = (i) => { const squares = state.squares.slice(); squares[i] = 'X'; setState({squares: squares}); }; const renderSquare = (i) => { return ( <Square value={state.squares[i]} onClick={() => {handleClick(i)}} /> ); }; const status = 'Next player: X'; return ( <div> <Status>{status}</Status> <BoardRow> {renderSquare(0)} {renderSquare(1)} {renderSquare(2)} </BoardRow> <BoardRow> {renderSquare(3)} {renderSquare(4)} {renderSquare(5)} </BoardRow> <BoardRow> {renderSquare(6)} {renderSquare(7)} {renderSquare(8)} </BoardRow> </div> ); }

게임 완성하기

순서 변경

지금은 게임판에 ‘X’만 표시되지만 ‘X’와 ‘O’를 번갈아 표시할 수 있게 만들어야 한다. Board 컴포넌트의 초기 state 값에 xIsNext 값을 true로 설정해 추가하고 순서가 바뀔 때마다 boolean 값을 뒤집어 setState에 저장한다. status도 다음 플레이어를 번갈아 가며 표시할 수 있도록 수정한다.

function Board() { const [state, setState] = useState({ squares: Array(9).fill(null), xIsNext: true }); const handleClick = (i) => { const squares = state.squares.slice(); squares[i] = state.xIsNext ? 'X' : 'O'; setState({ squares: squares, xIsNext: !state.xIsNext }); }; const status = `Next player: ${state.xIsNext ? 'X' : 'O'}`; // ... }

게임판에 ‘X’와 ‘O’가 번갈아 표시되고 상단의 Next player도 바뀌는 것을 확인할 수 있다.

Game Board

승패 결정

게임이 끝나면 승패 결과를 알려주어야 한다. Board.jscalcWinner 함수를 생성한다. calcWinnerlines에 우승하는 경우의 수를 배열로 지정하고 플레이어가 선택한 칸이 이에 해당하는지 순회하며 확인한다.

const calcWinner = (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; };

우승 플레이어를 표시할 수 있도록 status를 다음과 같이 수정한다.

const winner = calcWinner(state.squares); let status = (winner ? `Winner: ${winner}` : `Next Player: ${state.xIsNext ? 'X' : 'O'}`);

그리고 handleClick에 게임판이 다 채워지기 전에 승자가 결정됐거나 결정되지 않고 게임이 끝났다면 클릭을 무시하도록 코드를 추가한다.

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

승부가 결정되면 상단에 Winner가 표시되고 더 이상 게임이 진행되지 않는다.

Winner


기능 추가

기록 저장

앞서 게임을 구현할 때 squares 배열을 slice()를 사용해 직접 변경하지 않고 복사본을 만들어 사용했다. 이를 이용하면 지나간 순서를 다시 되돌리는 기능을 추가할 수 있다.

먼저, 과거 squares 배열을 history라는 배열에 저장한다. history는 첫 동작부터 마지막 동작까지 모든 게임판의 상태를 표현하며 아래와 같은 형태이다.

history = [ // 첫 동작이 발생하기 전 { squares: [ null, null, null, null, null, null, null, null, null, ] }, // 첫 동작이 발생한 이후 { squares: [ 'X', null, null, null, null, null, null, null, null, ] }, // 두 번째 동작이 발생한 이후 { squares: [ 'X', null, null, null, 'O', null, null, null, null, ] }, // ... ]

Game.js

Board 컴포넌트 내부의 초기 state는 삭제하고 Game 컴포넌트에 새로운 초기 state를 설정한다.

function Game() { const [history, setHistory] = useState([ { squares: Array(9).fill(null) } ]); const [xIsNext, setXIsNext] = useState(true); // ... }

가장 최근 기록을 사용하도록 업데이트하여 게임의 상태를 확인하고 표시할 수 있도록 아래 코드를 추가하고 Board 컴포넌트의 calcWinner와 관련된 메소드를 옮긴다.

const current = history[history.length - 1]; const winner = calcWinner(current.squares); let status = (winner ? `Winner: ${winner}` : `Next Player: ${xIsNext ? 'X' : 'O'}`);

Board 컴포넌트로 squares와 onClick props를 전달하기 위해 handleClick 함수를 Game 컴포넌트로 옮기고 새로운 기록 목록을 history로 연결하는 코드도 함께 작성한다. return 문 안에 status를 추가하고 squares, onClick props도 추가한다.

function Game() { // ... const handleClick = (i) => { const squares = current.squares.slice(); if (calcWinner(squares) || squares[i]) { return; } squares[i] = xIsNext ? 'X' : 'O'; setHistory(history.concat([{ squares }])); setXIsNext(!xIsNext); }; return ( <GameWrapper> <GameBoard> <Board squares={current.squares} onClick={(i) => handleClick(i)} /> </GameBoard> <GameInfo> <div>{status}</div> <ol>{/* TODO */}</ol> </GameInfo> </GameWrapper> ); }

Board.js

Board 컴포넌트에서는 renderSquare 함수 내부 Square 태그에 전달받은 squares와 onClick props를 Square 컴포넌트로 전달할 props 값에 넣어준다.

그리고 return 문 내부의 status도 중복되므로 삭제한다.

function Board({ squares, onClick }) { const renderSquare = (i) => { return ( <Square value={squares[i]} onClick={() => onClick(i)} /> ); }; return ( <div> // status 삭제 // ... </div> ); }

과거 기록 표시

저장한 기록을 플레이어에게 보여줄 차례이다. movesmap 함수로 과거 기록으로 돌아가는 버튼 목록을 저장한다. 게임 기록마다 버튼을 포함하는 리스트를 생성하며 버튼은 jumpTo 함수를 호출하는 onClick 핸들러를 가지고 있다. return 문 내부에 버튼 목록을 추가한다.

const moves = history.map((x, i) => { const desc = i ? `${i}번으로 이동` : `Game Start`; return ( <li key={i}> <button onClick={() => jumpTo(i)}>{desc}</button> </li> ); }); return ( <GameWrapper> // ... <GameInfo> <div>{status}</div> <ol>{moves}</ol> </GameInfo> </GameWrapper> );

jumpTo 함수는 현재 진행 중인 단계와 돌아가려는 단계의 정보를 포함해야 한다. 순서에 대한 초기 state로 stepNumber를 추가하고 함수 내부에 다음과 같이 작성한다.

const [stepNumber, setStepNumber] = useState(0); const jumpTo = (step) => { setStepNumber(step); setXIsNext((step % 2) === 0); };

마지막으로 버튼을 눌러 과거 기록으로 돌아가는 기능을 추가한다. handleClick 함수에 과거 기록으로 돌아갔을 때 이후 기록을 삭제할 수 있게 하는 past 변수와 이동 후 stepNumber를 업데이트하는 코드를 추가한다.

const handleClick = (i) => { const past = history.slice(0, stepNumber + 1); const current = past[past.length - 1]; const squares = current.squares.slice(); if (calcWinner(squares) || squares[i]) { return; } squares[i] = xIsNext ? 'X' : 'O'; setHistory(past.concat([{ squares }])); setXIsNext(!xIsNext); setStepNumber(past.length); };

Game 컴포넌트의 current를 수정하여 stepNumber에 맞는 이동을 렌더링하게 한다.

const current = history[stepNumber];

최종 결과물

간단한 tic tac toe 게임이 완성됐다.

Move history


References

https://ko.reactjs.org/tutorial/tutorial.html

Table Of Contents
nxnaxx blog © 2022-2024 Powered By Gatsby.