웹 게임을 만들며 배우는 React(틱택토)

짜스의 하루 ·2024년 5월 24일

useReduce

useReducer는 React의 훅(Hook) 중 하나로, 컴포넌트에서 복잡한 상태 로직을 관리할 때 유용하게 사용할 수 있다.

useReducer의 기본 구조

const [state, dispatch] = useReducer(reducer, initialState);

여기서 reducer는 상태 전이 로직을 정의하는 함수이고, initialState는 초기 상태를 의미한다.

reducer 함수
--> reducer 함수는 두 개의 인자를 받는다:

현재 상태 (state)
액션 (action)

const reducer = (state, action) => {
  switch (action.type) {
    case SET_WINNER:
      return {
        ...state,
        winner: action.winner,
      };
  }
};

switch 문:

  • action.type에 따라 다른 상태 전이 로직을 수행한다.
  • 여기서는 SET_WINNER라는 액션 타입만을 처리하고 있다.

case SET_WINNER::

  • SET_WINNER 액션 타입이 전달되면 실행된다.
  • 새로운 상태 객체를 반환한다.
  • ...state는 기존 상태를 그대로 복사하는 스프레드 연산자이다.
  • winner: action.winner는 state 객체의 winner 속성을 action.winner 값으로 업데이트한다.

//state.winner = action.winner 처럼 이렇게 바로 바꾸면 안된다.
--> 새로운 객체를 만들어서 바뀐 값만 바꿔주어야 한다!

action 객체
action 객체는 주로 두 개의 속성을 가진다.

type: 어떤 액션인지 명시하는 문자열
payload: 선택적으로, 상태 업데이트에 필요한 데이터

 const onClickTable = useCallback(() => {
    dispatch({ type: SET_WINNER, winner: 'O' });
  }, []);

{ type: SET_WINNER, winner: 'O' }

  • type 속성은 액션의 타입을 나타내며, 여기서는 SET_WINNER이다.
  • winner 속성은 액션의 추가 데이터를 나타내며, 여기서는 'O'이다.

간단한 사용 예제를 살펴보자

import React, { useReducer } from 'react';

const initialState = { count: 0 };

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'RESET':
      return { count: 0 };
    default:
      return state;
  }
};

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>Decrement</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
    </div>
  );
};

export default Counter;
  • initialState는 { count: 0 }이다. 즉 초기값 : 0
  • reducer 함수는 액션 타입에 따라 count 상태를 증가, 감소 또는 초기화한다.
  • dispatch 함수는 액션 객체를 reducer 함수로 보낸다.

틱택토 hooks로 구성해보기

사실 정리를 하려고 하는 지금도 이해가 잘 안된다(?)
그래도 한 번 꾸역꾸역 해보도록 하겠다


이러한 표가 있다.
표를 나타낼 때, table, tr, td 각각의 컨포넌트를 만들어 볼 것이다. (최대한 작게 쪼개는 것이 유용하다)

TicTacToe.jsx

간단하게 기본적인 코드만 먼저 작성해 보았다.

  • initialState: 게임의 초기 상태를 정의한다.
    winner는 현재 승리자를 나타내고, turn은 현재 차례를 나타내며, tableData는 게임 보드의 상태를 나타낸다.

  • SET_WINNER: 리듀서에서 사용될 액션 타입을 상수로 정의한다. 보통은 이렇게 따로 상수로 정의하는 편이라고 한다.

  • TicTacToe 컴포넌트: 메인 컴포넌트로, 게임 로직을 처리한다. UseReducer를 사용하여 상태 관리를 하고 있다.

  • reducer: 리듀서 함수로, 액션에 따라 상태를 업데이트한다. 여기서는 SET_WINNER 액션이 들어오면 winner를 업데이트 한다.

    --> dispatch({ type: SET_WINNER, winner: 'O' })는 SET_WINNER 타입의 액션을 dispatch하여 winner 상태를 'O'로 변경하겠다는 의미를 나타낸다.

  • onClickTable 콜백 함수 : 테이블을 클릭했을 때 호출되는 함수이다. 여기서는 dispatch 함수를 사용하여 SET_WINNER 액션을 발생시킨다.

그럼 여기서 Table, Tr, Td의 컨포넌트를 작성하러 가보자

Table

틱택토 게임의 게임 보드를 렌더링하는 <Table> 컴포넌트이다.

tableData: 게임 보드의 데이터를 나타내는 배열 --> 각 요소는 셀의 데이터를 나타낸다.
dispatch: 리듀서로 전달되는 액션을 발생시키는 함수이다.

컴포넌트는 <table> 요소를 반환하고, 이어서 <tbody> 요소를 포함한다. 그리고 tableData.length 만큼 반복하는 map 함수를 사용하여 각 행을 나타내는 <Tr> 컴포넌트를 렌더링한다.
--> <Tr> 컴포넌트에는 다음과 같은 props가 전달된다. :

  • key: React에서 목록 요소를 렌더링할 때 각 항목에 고유한 키를 제공하는 데 사용
  • dispatch: 액션을 발생시키는 함수(Td에서 사용해야 하기 때문에 내려준다)
  • rowIndex: 현재 행의 인덱스
  • rowData: 현재 행에 대한 데이터 --> 이 데이터는 각 셀의 값이 포함된 배열을 나타낸다.
    이렇게 구성된 테이블은 각 셀의 데이터를 표시하고, 사용자가 해당 셀을 클릭할 때 dispatch 함수를 호출하여 상태를 업데이트한다.

Tr

이 코드는 틱택토 게임에서 한 행을 나타내는 <Tr> 컴포넌트

  • rowData: 현재 행에 대한 데이터를 나타내는 배열 --> 각 요소는 셀의 데이터를 나타낸다.
  • rowIndex: 현재 행의 인덱스
  • dispatch: 리듀서로 전달되는 액션을 발생시키는 함수

컴포넌트는 각 행을 나타내는 <tr> 요소를 반환한다. 그리고 rowData.length만큼 반복하는 map 함수를 사용하여 각 셀을 나타내는 <Td> 컴포넌트를 렌더링한다.

<Td> 컴포넌트에는 다음과 같은 props가 전달한다:

  • key: React에서 목록 요소를 렌더링할 때 각 항목에 고유한 키를 제공하는 데 사용된다.
  • dispatch: 액션을 발생시키는 함수이다.
  • rowIndex: 현재 행의 인덱스이다.
  • cellIndex: 현재 셀의 인덱스이다.
  • cellData: 현재 셀에 대한 데이터이다.
    이렇게 구성된 <Tr> 컴포넌트는 특정 행의 각 셀을 렌더링하고, 각 셀에 대한 데이터를 <Td> 컴포넌트에 전달하여 화면에 표시한다.

Td

이 코드는 <Td> 컴포넌트를 정의한다. 이 컴포넌트는 표의 각 셀을 나타낸다.

  • rowIndex: 현재 셀이 속한 행의 인덱스를 나타내는 prop
  • cellIndex: 현재 셀의 열 인덱스를 나타내는 prop
  • dispatch: 부모 컴포넌트에서 전달되는 dispatch 함수로, 액션을 발생시키는 데 사용된다.
  • cellData: 현재 셀에 표시될 데이터를 나타내는 prop

컴포넌트 내부에서는 onClickTd라는 함수를 정의하고 있다. 이 함수는 사용자가 셀을 클릭했을 때 호출되는 콜백 함수로, 다음과 같은 작업을 수행한다.

  • 현재 셀의 행과 열 인덱스를 콘솔에 출력한다.(1,1 2,2 이런식으로 출력될 것이다)
  • 만약 현재 셀에 이미 데이터가 채워져 있다면(이미 클릭되었다면), 함수를 종료한다.

그렇지 않다면, dispatch 함수를 사용하여 CLICK_CELL이라는 타입의 액션을 발생시킨다. 이 액션에는 현재 셀의 행과 열 인덱스가 포함된다.
그리고 dispatch에 정의된 내용을 가지고 tictactoe에서 reduce 함수에서 어떻게 액션을 발생시킬지 정의한다.

마지막으로, 셀이 클릭 가능하도록 <td> 엘리먼트를 렌더링하고, 클릭 시 onClickTd 함수가 호출되도록 설정한다. 현재 셀에 표시할 데이터는 cellData prop을 통해 전달된다.

그렇다면 다시 TicTacToe.jsx에 돌아가서 마저 코드를 작성해보자

먼저 td에 작성해 두었던 CLICK_CELL에 대해서 살펴보자

case CLICK_CELL: {
        const tableData = [...state.tableData];
        tableData[action.row] = [...tableData[action.row]];
        tableData[action.row][action.cell] = state.turn;
        return {
          ...state,
          tableData,
          recentCell: [action.row, action.cell],
        };
      }

먼저, 현재의 tableData 배열을 복사하여 변경할 준비를 한다. --> 이는 불변성을 유지하기 위해 필요하다

다음으로, 새로운 tableData를 생성한다. 기존의 tableData 배열에서 해당 액션에 따라 변경할 행을 선택한다. 이를 위해 tableData[action.row]를 복사하여 새로운 배열을 생성한다.

그 다음, 새로운 행(tableData[action.row])에서 액션에 지정된 셀(action.cell)의 값을 현재 플레이어의 표시(state.turn)로 설정한다.

변경된 tableData와 최근에 클릭된 셀의 정보([action.row, action.cell])를 새로운 상태 객체에 포함하여 반환한다.

이제 해야 할 것은 무엇이 있을 까?
화면이 로딩이 된 후,
o, x를 번갈아가면서 게임이 진행이 되고
세개가 연달아 되면 게임이 종료된다.
게임이 종료가 되면 다시 게임이 시작이 된다.

--> 이를 코드로 작성하기 위해서는 useEffect()로 작성해야 한다!

useEffect()

useEffect(() => {
    const [row, cell] = recentCell;
    if (row < 0) {
      return;
    }

    let win = false;

    if (
      tableData[row][0] === turn &&
      tableData[row][1] === turn &&
      tableData[row][2] === turn
    ) {
      win = true;
    }
    if (
      tableData[0][cell] === turn &&
      tableData[1][cell] === turn &&
      tableData[2][cell] === turn
    ) {
      win = true;
    }
    if (
      tableData[0][0] === turn &&
      tableData[1][1] === turn &&
      tableData[2][2] === turn
    ) {
      win = true;
    }
    if (
      tableData[0][2] === turn &&
      tableData[1][1] === turn &&
      tableData[2][0] === turn
    ) {
      win = true;
    }
    console.log(win, row, cell, tableData, turn);
    if (win) {
      dispatch({ type: SET_WINNER, winner: turn });
      dispatch({ type: RESET_GAME });
    } else {
      let all = true; // all이 true라면 무승부 라는 뜻
      tableData.forEach((row) => {
        row.forEach((cell) => {
          if (!cell) {
            all = false;
          }
        });
      });

      if (all) {
        dispatch({ type: SET_WINNER, winner: null });
        dispatch({ type: RESET_GAME });
      } else {
        dispatch({ type: CHANGE_TURN });
      }
    }
  }, [recentCell]);

이 useEffect는 게임 상태의 변화를 감지하고, 이에 따라 게임의 승자를 확인하고 다음 단계로 진행한다.

  • 최근에 클릭된 셀의 정보를 가져온다. 만약 최근에 클릭된 셀이 없다면(행과 열이 음수일 경우), 아무 작업도 하지 않고 종료된다.

  • win이라는 변수를 선언하고 초기값을 false로 설정한다. win은 현재 플레이어가 이겼는지 여부를 나타내는 불리언 값이다.

  • 각 행, 열, 대각선의 상태를 확인하여 현재 플레이어가 해당 라인을 완성했는지 여부를 판단합니다. 이때 win 변수를 true로 설정한다.

  • win 변수가 true이면(즉, 현재 플레이어가 게임을 이겼다면), dispatch 함수를 사용하여 SET_WINNER 액션을 발생시킨다. 이때 winner에는 현재 플레이어의 정보인 turn을 전달한다. 그리고 RESET_GAME 액션도 발생시켜 게임을 초기화한다.

  • win 변수가 false인 경우, 즉 승자가 없는 상황이라면 모든 셀이 채워져 있는지 확인합니다. 이를 위해 all이라는 변수를 선언하고 초기값을 true로 설정한다. 그리고 tableData 배열의 모든 요소를 확인하면서 모든 셀이 채워져 있는지 확인한다.

  • if (!cell)은 해당 cell이 비어 있는지(cell이 빈 문자열이나 null 등 falsy한 값인지) 확인한다.
    만약 cell이 비어 있으면, all 변수를 false로 설정한다. 이는 빈 셀이 하나라도 있다는 의미이다.
    따라서 모든 셀이 채워져 있지 않으면 무승부가 아닌 상황을 의미한다.

무승부

  • if (all)는 모든 셀이 채워져 있는지를 확인한다. all은 이전에 tableData 배열을 검사하면서 모든 셀이 채워져 있을 때 true로 설정된다.

  • all이 true이면, 게임판의 모든 셀이 채워져 있고 승자가 없다는 의미이다. 따라서 이는 무승부 상황이다.

  • dispatch({ type: SET_WINNER, winner: null }) 는 승자를 null로 설정하는 액션을 발생시킨다. 이 액션은 상태 관리 로직에서 승자가 없음을 나타내도록 한다.

  • dispatch({ type: RESET_GAME }) 는 게임을 초기화하는 액션을 발생시킨다. 이 액션은 상태를 초기 상태로 리셋하여 게임을 다시 시작할 수 있도록 한다.

  • else는 all이 false인 경우를 처리한다. 즉, 아직 비어 있는 셀이 하나라도 있다는 의미를 나타낸다.

  • dispatch({ type: CHANGE_TURN }) 는 턴을 변경하는 액션을 발생시킨다. 이 액션은 현재 플레이어의 턴을 다른 플레이어에게 넘기는 역할을 한다.

그렇다면, 이제 CHANGE_TURN, RESET_GAME 코드를 작성하러 가보자

case CHANGE_TURN: {
      return {
        ...state,
        turn: state.turn === 'O' ? 'X' : 'O',
      };
    }
    case RESET_GAME: {
      return {
        ...state,
        turn: 'O',
        tableData: [
          ['', '', ''],
          ['', '', ''],
          ['', '', ''],
        ],
        recentCell: [-1, -1],
      };
    }

여기서 알고 넘어가야할 부분

return {
  ...state,
}

...state는 현재 상태를 복사한다. 이는 새로운 상태 객체를 만들기 위해 기존 상태의 모든 속성을 포함하는 것이다. --> 스프레드 문법!

CHANGE_TURN 액션

turn: state.turn === 'O' ? 'X' : 'O', : turn을 변경한다
turn 이 O라면, X로, X라면, O으로 변경!

RESET_GAME 액션

turn: 'O' : turn을 'O'로 설정--> 게임이 다시 시작할 때 'O' 플레이어가 먼저 시작하도록 한다.
tableData를 모두 빈 문자열로 채워진 3x3 배열로 초기화
recentCell: [-1, -1] : 이는 최근에 선택된 셀을 초기화하여, 게임 시작 시 아직 아무 셀도 선택되지 않았음을 나타낸다.

간단한 게임인 줄 알았지만 .. 정말 생각할 부분도,
특히 3X3을 쪼개야 한다는 점이 어렵기도하고, useReducer를 처음 사용해 보니까
해매기도 한 것 같다!!

profile
2024. 01. 02 ~ 백앤드 공부 시작, 2024. 04.01 ~ 프론트 공부 시작

0개의 댓글