[프로젝트] 틱택토 게임 (2)

rin·2020년 5월 17일
0

React

목록 보기
7/16
post-thumbnail

ref. https://ko.reactjs.org/tutorial/tutorial.html
위 자습서의 틱택토 게임 개발을 클래스 컴포넌트가 아닌 함수형 컴포넌트와 Hook을 이용해 진행합니다. 필자는 리액트를 처음 사용해보므로 코드가 더럽거나 올바르지 않을 수 있습니다.
이 글을 작성하는 이유는 제 개인적인 리액트 학습을 위한 것입니다.

지난 게시글인 틱택토 게임 (1)에서는 X와 O를 번갈아 체크하는 것 까지 완성하였다.

문제는 이미 X 혹은 O 로 표시된 박스를 클릭하면 덮어씌우는 것이 가능하단 것이다. 이를 제어하기 위해 코드를 약간 수정하도록 하겠다.

중복 클릭 시 이벤트

Board.js

    const handleClick = (event) => {
        const [row, column] = getLocation(event);
        if ( squares[row][column].value == null ){
            setSquares(getNewSquares(row, column));
            setXIsNext(!xIsNext);
        }
    }

    const getNewSquares = (row, column) => {
        var newSquares = squares.slice();
        newSquares[row][column].value = (xIsNext ? 'X' : 'O')
        return newSquares;
    }

    const getLocation = (event) => {
        var index = parseInt(event.target.dataset.index);
        var row = parseInt(index / squares[0].length);
        var column = index - squares[0].length * row;
        return [row, column];
    }

아직 value가 null 인 (한 번도 클릭되지 않은) 컴포넌트에 대해서만 상태값을 변경해 줄 것이다. 따라서 현재 상태를 체크하기 위해 getLocation의 위치를 getNewSquares에서 handleClick으로 빼내었다. 또한, getLocation으로 얻어온 좌표를 재활용하기 위해 getNewSquares의 Argument를 event객체에서 row, column으로 변경하였다.

다시 실행해보면, 클릭한 곳을 다시 클릭했을 때는 next player와 해당 칸의 값이 변하지 않는 것을 확인 할 수 있다.

승자 결정하기

틱택토 게임은 다음과 같은 경우에 게임이 종료된다.

  1. 가로 or 세로 or 대각선으로 한 줄이 같은 모양일 경우, 해당 플레이어가 승리한다.
  2. 빈 칸이 없는 경우 무승부로 종료된다.

재랜더링 될 때 마다 이를 검사하기 위해 Effect Hook을 사용하도록 하겠다.
위 이미지에서 좌측의 lines는 승리하기 위해 점유해야할 Square의 인덱스 모음이다. (우측의 숫자는 이해를 돕기위해 숫자를 써 둔 것이니 신경쓰지 않아도 된다.) 이 인덱스는 Board의 상태값인 squares가 초기화될 때 고유하게 가지는 index 값과 동일하다. getLocation 함수는 인덱스를 이용해 x, y 좌표(row, column으로 표시)를 구하고 있기 때문에 이를 재활용 할 수 있도록 약간의 변경을 가할 것이다.

우선, event 객체를 받아와 index를 가져오던 getLocation 함수를 다음처럼 index를 바로 사용하도록 변경하였다.

변경 전변경 후

이에 맞춰 handleClick 함수에서 getLocation을 호출 할 때 Argument로 event.target.dataset.index를 넣어주도록 한다.

라인을 만든 경우

본격적으로 승자를 가려내는 로직을 추가하도록 하겠다.

승자를 표시하는 상태인 winner를 추가한다. 코드를 모두 작성하고 든 생각인데, 굳이 상태로 사용해서 재렌더링이 되도록 만들 필요는 없는 것 같다.🤔 그냥 위의 status 처럼 전역 변수로 사용해도 되는 걸까..?

useEffect 에서는 두번째 인자로 squares 상태를 넘김으로써 squares가 변경되고 재랜더링 된 후에 로직을 수행하도록 한다.

내부 알고리즘은 간단하다.

  1. 모든 가능한 line을 확인하는데
  2. getLocation을 이용해 얻어낸 세개의 좌표쌍을 이용하여
  3. line에 해당되는 square 들이 null이 아니고 동일한 value를 가졌는지 검사한다.

전체 코드는 아래와 같다.

import React, {useEffect, useState} from "react";
import Square from "./Square.js";

const Board = ({init}) => {
    const status = 'Next Player : ';
    const [squares, setSquares] = useState(init);
    const [xIsNext, setXIsNext] = useState(true);
    const [winner, setWinner] = useState(null);


    useEffect(() => {
        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 (isWin(getLocation(a), getLocation(b), getLocation(c))) {
                setWinner(xIsNext ? 'O' : 'X');
                break;
            }
        }
    }, [squares]);

    const isWin = (...locations) => {
        for (let i = 1; i < locations.length; ++i) {
            const [rowNow, colNow] = locations[i - 1];
            const [rowNext, colNext] = locations[i];
            if (squares[rowNow][colNow].value == null || squares[rowNow][colNow].value !== squares[rowNext][colNext].value) {
                return false;
            }
        }
        return true;
    }

    const handleClick = (event) => {
        const [row, column] = getLocation(event.target.dataset.index);
        if (squares[row][column].value == null) {
            setSquares(getNewSquares(row, column));
            setXIsNext(!xIsNext);
        }
    }

    const getNewSquares = (row, column) => {
        var newSquares = squares.slice();
        newSquares[row][column].value = (xIsNext ? 'X' : 'O')
        return newSquares;
    }

    const getLocation = (index) => {
        var row = parseInt(index / squares[0].length);
        var column = index - squares[0].length * row;
        return [row, column];
    }

    return (
        <div>
            {winner == null ?
                <div className="status">{status + (xIsNext ? 'X' : 'O')}</div> :
                <div className="status">{'Winner is ' + winner + ' !!!'}</div>
            }
            {squares.map(row =>
                <div className="board-row">
                    {row.map(column =>
                        <Square
                            value={column.value}
                            index={column.index}
                            handleClick={handleClick}
                        />
                    )}
                </div>
            )}
        </div>
    );
}

export default Board;

무승부로 끝나는 경우

더 이상 클릭할 칸이 없다면 무승부로 게임은 종료되게 된다. 이를 위해 코드를 살짝 수정해보도록 하겠다.

이전에 "Next Player : " 라는 문자열이었던 statusexplain이 대신하고 다음처럼 상태로 변경하였다. 클릭을 한 경우 xIsNext라는 상태를 이용한 삼항 연산자로 컴포넌트 출력 부분에서 직접 'X' 혹은 'O'를 출력해 주던 것과 반대로, "Next Player : X(or O)" 라는 문자열이 하나의 상태로써 관리된다.

또한 무승부로 끝난 상황을 판단하기 위해, 클릭횟수를 사용할 것이므로 clickCount라는 상태를 추가하였다. 빈 칸이 클릭될 때 마다 clickCount는 하나씩 늘어나고, 전체 칸과 그 개수가 같아질 경우 모든 칸이 클릭됐음을 뜻하므로 무승부로 게임이 종료되도록 한다.
전체 코드는 다음과 같다.

import React, {useEffect, useState} from "react";
import Square from "./Square.js";

const Board = ({init}) => {
    const explain = 'Next Player : ';
    const [status, setStatus] = useState(explain + 'X');
    const [squares, setSquares] = useState(init);
    const [xIsNext, setXIsNext] = useState(true);
    const [winner, setWinner] = useState(null);
    const [clickCount, setClickCount] = useState(0);

    useEffect(() => {
        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 (isWin(getLocation(a), getLocation(b), getLocation(c))) {
                setWinner(xIsNext ? 'O' : 'X');
                return;
            }
        }

        if(clickCount == squares.length * squares[0].length){
            setStatus('GAME-OVER');
            return;
        }
        
    }, [squares]);

    const isWin = (...locations) => {
        for (let i = 1; i < locations.length; ++i) {
            const [rowNow, colNow] = locations[i - 1];
            const [rowNext, colNext] = locations[i];
            if (squares[rowNow][colNow].value == null || squares[rowNow][colNow].value !== squares[rowNext][colNext].value) {
                return false;
            }
        }
        return true;
    }

    const handleClick = (event) => {
        const [row, column] = getLocation(event.target.dataset.index);
        if (squares[row][column].value == null) {
            setClickCount(clickCount+1);
            setSquares(getNewSquares(row, column));
            setStatus(explain + (!xIsNext ? 'X' : 'O'));
            setXIsNext(!xIsNext);
        }
    }

    const getNewSquares = (row, column) => {
        var newSquares = squares.slice();
        newSquares[row][column].value = (xIsNext ? 'X' : 'O')
        return newSquares;
    }

    const getLocation = (index) => {
        var row = parseInt(index / squares[0].length);
        var column = index - squares[0].length * row;
        return [row, column];
    }

    return (
        <div>
            {winner == null ?
                <div className="status"><h3>{status}</h3></div> :
                <div className="status"><h3>{'Winner is ' + winner + ' !!!'}</h3></div>
            }
            {squares.map(row =>
                <div className="board-row">
                    {row.map(column =>
                        <Square
                            value={column.value}
                            index={column.index}
                            handleClick={handleClick}
                        />
                    )}
                </div>
            )}
        </div>
    );
}

export default Board;

위와 같이 승자를 가리는 코드가 무승부를 가리는 코드보다 위에 있는데, 이는 마지막 빈 칸을 클릭함과 동시에 line을 완성할 가능성이 있기 때문이다. 또한, useEffect Hook에서는 return으로 clean-up을 수행하는 콜백 함수를 반환하지만, 위와 같이 아무것도 반환하지 않음으로써 clean-up으로 작동되지도, 나머지 effect 로직을 수행하지도 않도록 만들 수 있다.

시간 여행 추가하기

마지막으로 이전 차례로 시간을 되돌리는 로직을 추가할 것이다. 클릭을 하면 매번 새로운 상태로 squares가 대체되었는데, 이 모든 내역을 관리하는 history state를 생성한다.

Game 컴포넌트로 상태 끌어 올리기

history state는 최상위 컴포넌트인 Game에 위치시킬 것이다. Board 컴포넌트에서 관리하던 squares 및 모든 상태를 Game으로 끌어올리고 history의 가장 마지막 배열(가장 최근 상태)을 Board 컴포넌트로 넘겨 렌더링하도록 만든다.

결론적으로 모든 상태가 Game으로 이전되고 Board는 이를 전달받아서 랜더링에 사용하게 된다.
가장 크게 달라진 점은 2차원 배열이었던 squares가 👉squares라는 Attribute를 가진 오브젝트의 1차원 배열인 history로 바뀌었다는 것이다.
이전에도 Game 컴포넌트 내에 있던 squares를 초기화하는 함수이다. 상수명이 ROW → MAXIMUM_ROW, COLUMN → MAXIMUM_COLUMN 으로 변경된 것을 제외하면 마지막 return만 다른 형태로 바뀌었다.

❗️NOTE :: 깊은 복사
이전에 getNewSquares 함수에서는 splice()를 이용해 얕은 복사를 하였는데, history를 계속해서 쌓기 위해선 아예 새로운 배열(squares)을 생성하는 깊은 복사를 사용해야했다. 이를 위해 위와 같이 직렬화 후에 다시 파싱하는 과정을 거쳐야한다.

그 외에는 squares로 바로 접근하던 것을 아래와 같이 history의 인덱스와 attribute명을 이용하여 접근하는 식의 변화가 있었다.
Game 컴포넌트에서 빈 칸 이었던 game-info div태그 부분도 현재 상태가 나타나도록 코드를 추가해준다.

전체 코드는 아래와 같다.
🔎Game.js

import React, {useEffect, useState} from "react";
import Board from './Board.js';

const Game = () => {

    const MAXIMUM_ROW = 3, MAXIMUM_COLUMN = 3;
    const initializeSquares = () => {
        const init = Array(MAXIMUM_ROW).fill(null);
        for (var i = 0; i < MAXIMUM_ROW; ++i) {
            var columns = Array(MAXIMUM_COLUMN).fill(null);
            for (var j = 0; j < MAXIMUM_COLUMN; ++j) {
                columns[j] = {index: i * MAXIMUM_COLUMN + j, value: null};
            }
            init[i] = columns;
        }
        return [{squares: init}];
    }

    const explain = 'Next Player : ';
    const [status, setStatus] = useState(explain + 'X');
    const [history, setHistory] = useState(initializeSquares);
    const [xIsNext, setXIsNext] = useState(true);
    const [winner, setWinner] = useState(null);
    const [clickCount, setClickCount] = useState(0);

    useEffect(() => {
        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 (isWin(getLocation(a), getLocation(b), getLocation(c))) {
                setWinner(xIsNext ? 'O' : 'X');
                return;
            }
        }

        if (clickCount == MAXIMUM_ROW * MAXIMUM_COLUMN) {
            setStatus('GAME-OVER');
            return;
        }

    }, [history]);

    const isWin = (...locations) => {
        for (let i = 1; i < locations.length; ++i) {
            const [rowNow, colNow] = locations[i - 1];
            const [rowNext, colNext] = locations[i];
            const mostRecentSquares = history[history.length - 1].squares;
            if (mostRecentSquares[rowNow][colNow].value == null
                || mostRecentSquares[rowNow][colNow].value !== mostRecentSquares[rowNext][colNext].value) {
                return false;
            }
        }
        return true;
    }

    const handleClick = (event) => {
        const [row, column] = getLocation(event.target.dataset.index);
        if (history[history.length - 1].squares[row][column].value == null) {
            setClickCount(clickCount + 1);
            setHistory(history.concat({squares: getNewSquares(row, column)}));
            setStatus(explain + (!xIsNext ? 'X' : 'O'));
            setXIsNext(!xIsNext);
            console.log(history);
        }
    }

    const getNewSquares = (row, column) => {
        var newSquares = JSON.parse(JSON.stringify(history[history.length - 1].squares));
        newSquares[row][column].value = (xIsNext ? 'X' : 'O');
        return newSquares;
    }

    const getLocation = (index) => {
        var row = parseInt(index / MAXIMUM_ROW);
        var column = index - MAXIMUM_COLUMN * row;
        return [row, column];
    }

    return (
        <div className="game">
            <div className="game-board">
                <Board
                    squares={history[history.length - 1].squares}
                    handleClick={handleClick}
                    status={status}
                    winner={winner}
                />
            </div>
            <div className="game-info">
                {winner == null ?
                    <div className="status">{status}</div> :
                    <div className="status">{'Winner is ' + winner + ' !!!'}</div>
                }
                <ol>{/* TODO */}</ol>
            </div>
        </div>
    );
};

export default Game;

🔎Board.js

import React, {useEffect, useState} from "react";
import Square from "./Square.js";

const Board = ({squares, handleClick, status, winner}) => {

    return (
        <div>
            {winner == null ?
                <div className="status"><h3>{status}</h3></div> :
                <div className="status"><h3>{'Winner is ' + winner + ' !!!'}</h3></div>
            }
            {squares.map(row =>
                <div className="board-row">
                    {row.map(column =>
                        <Square
                            value={column.value}
                            index={column.index}
                            handleClick={handleClick}
                        />
                    )}
                </div>
            )}
        </div>
    );
}

export default Board;

실행해보면 좌측 이미지처럼 오른편에 상태 표시 문구가 하나 더 추가됐음을 볼 수 있다. 또한 handleClick 메소드에 console.log(history)가 있기 때문에 콘솔창에서 빈 칸을 누를 때마다 히스토리가 쌓이는 것을 확인 할 수 있을 것이다. 총 여섯번 클릭되었기 때문에 history의 개수가 1 → 2 → ... → 6개까지 증가한다.

변경된 실행 화면콘솔창

과거 이동을 위한 목록 출력하기

game-info 부분을 수정하여 과거로 이동할 버튼을 출력할 것이다. 위 이미지는 Game의 return 로직이며 변경된 부분은 하늘색 박스이다. 전체 history 만큼 순환하며 move(=index)가 0이면 초기상태, 아닐 경우에는 history에 저장된 스냅샷을 가리킨다. console.log에 의해 출력되는 것은 다음과 같다.

게임 화면콘솔창

오브젝트((2) [{..}, {..}] 꼴)는 handleClick 메소드에서 출력한 것이고, move : step으로 출력된 로그 개수와 좌측의 내역 개수가 클릭 할 때 마다 동일한 것을 확인 할 수 있다. 예를 들어 현재 이미지에선 history[0]에 대응되는 Go to game start버튼과 move = 0:[object Object]에 해당하는 로그가 서로 매칭된다. 총 다섯개의 내역이 작성되었으므로, 마지막 로그도 move 0부터 4까지 다섯개가 연달아 출력된 것을 확인 할 수 있다.

❗️NOTE : Key
React에서는 각 요소를 구분하기위해 key를 사용한다. key는 상태가 아닌, 변하지 않는 고유값이기 때문에 상태관리와는 별개이다.

key로는 중복되지 않고, 노출되어도 괜찮은 고유한 값이 사용되는데 이를 지정해 주지 않으면 React는 경고 메세지를 띄우며 배열의 인덱스를 임의의 key로 자동할당한다.

배열의 인덱스를 키로 사용하는 것은 리스트 아이템 순서를 바꾸거나 아이템을 추가/제거 할 때 문제가 된다. 명시적으로 key={i}를 전달하면 경고가 나타나지는 않지만 동일한 문제를 일으키기 때문에 대부분의 경우에 추천하지 않는다.

키는 전역에서 고유할 필요는 없으며 컴포넌트와 관련 아이템 사이에서는 고유한 값을 가져야 한다.

위와 같이 key를 추가하였긴 했지만, 설명 했듯 배열의 인덱스를 고유키로 사용하는 것은 좋지않다.🤔 틱택토 게임에서는 history state에서 과거의 이동 정보는 순차적으로 증가하기 때문에 각 인덱스=move가 고유한 ID로 직결된다. 다시 말하면 move는 순서가 바뀌거나 삭제되거나 중간에 삽입될 수 없기 때문에 move의 인덱스를 키로 사용해도 안전하다.

목록을 출력하면 상태값을 되돌리기

마지막으로 히스토리 내역을 클릭하면 되돌아가는 jumpTo 함수를 작성한다.

0번째(아무도 클릭 안 한 상태) = X, 1번째(X가 처음으로 클릭한 다음) = O, 2번째(X, O가 한 번씩 클릭한 다음) = X, ... 로 차례가 정해지므로 move % 2 === 0으로 true/false를 설정하였다.

slice의 첫번째 argument는 잘라낼 시작 인덱스를, 두번째 argument는 잘라낼 갯수를 뜻하므로 move가 0부터 시작하는 것을 고려하여 +1 한 값을 넘겨주었다.

🙋🏻 게임을 실행해보자!!

진행 화면3. Go to move #2 를 클릭게임을 진행해 X가 승리5. Go to move #5 를 클릭

모든 코드는 github에서 확인할 수 있습니다.

profile
🌱 😈💻 🌱

0개의 댓글