본문 바로가기
💻CODING/react. vue

[react] react로 지뢰찾기 게임 만들기 ver.1(ft. context API, useReducer)

by 코딩하는 갓디노 2021. 4. 14.

react 지뢰찾기

 

리액트로 구현한
지뢰찾기 게임(ver.1) 입니다. 
- 지뢰판 세팅 -

 

예제는 인프런의 제로초, "조현영"님의 강의를 들으면서 공부한 내용입니다.

 

순수 자바스크립트로 만든 지뢰찾기 게임은 아래 포스트로 이동해주세요.

https://goddino.tistory.com/107

 

[js] 자바스크립트로 지뢰찾기 게임 구현하기

자바스크립트를 이용하여, 지뢰찾기 게임을 구현하는 예제입니다. 예제는 인프런의 제로초, "조현영"님의 강의를 들으면서 공부한 내용입니다. 순서도 기능 REMARK 실행 버튼 클릭 후 지뢰

goddino.tistory.com

 

구현내용

· useReduer, context API 이용하여 지뢰칸 세팅
· 마우스 오른쪽 클릭하여 로직 작성하기

 

컴포넌트 구조

MineSearch.jsx >Form.jsx
MineSearch.jsx > MineTable.jsx > MineTr.jsx > MineTd.jsx

 

Context API

useReducer를 사용할때, 부모로부터 하위간의 레벨이 많을 경우, 계속해서 props로  dispatch를 넘겨줘야 하는 번거로움을 해결하기 위해  context API를 사용합니다.

useReducer에 대한 설명은 아래로 이동해주세요.

https://goddino.tistory.com/153

 

[js] react의 useReducer 사용법

useReducer state가 많을 경우, state를 하나로 묶어주는 역할을 합니다. useReducer 사용 방법 state를 바꾸기 위해 action을 만들고, 이벤트를 통해 action을 dispatch하여 reducer 함수의 action을 불..

goddino.tistory.com

 

createContext와 Provider

  • createContext() 함수를 사용하여 Context 만들기
  • Context API의 데이터에 접근해야 하는 하위 컴포넌트를 return 안에서 Provider로 감싸기
  • 전달할 데이터는 value={} 안에 넣기
  • context API는 성능 최적화가 힘들기 때문에  useMemo()로 객체값을 기억하여 캐싱 작업 하기

 

상위 컴포넌트

import React, { useContext } from 'react';

export const TableContext(이름 아무거나) = createContext({ //export하여 하위 컴포넌트에서 import
  //초깃값 설정
});

return (
    //TableContrect.Provider value에서 data를 넘겨주었기 때문에 
    //하위 컴포넌트에서 자유롭게 데이터를 쓸 수 있음
    <TableContext.Provider value={{ tableData: state.tableData, dispatch }}>
      ...하위 컴포넌트
    </TableContext.Provider>
  );

 

하위 컴포넌트

import React, { useContext } from 'react';
import { TableContext } from "상위 컴포넌트 이름";

const 하위 컴포넌트 = () => {
  //const value = useContext(TableContext);
  //value.dispatch = useContext(TableContext);
  const { dispatch } = useContext(TableContext); //구조분해 dispatch 접근
 ...
};

 

화면 결과

지뢰판

 

client.jsx

import React from 'react';
import ReactDom from 'react-dom';
import MineSearch from './MineSearch';

ReactDom.render(<MineSearch />, document.querySelector('#root'));

 

index.html

<!DOCTYPE html>
<html lang="ko">
  <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>지뢰찾기</title>
    <style>
      table {
        border-collapse: collapse;
      }
      td {
        border: 2px solid #ddd;
        width: 30px;
        height: 30px;
        text-align: center;
      }
      input {
        max-width: 80px;
      }
    </style>
  </head>

  <body>
    <div id="root"></div>
    <script src="./dist/app.js"></script>
  </body>
</html>

 

MineSearh.jsx 

createContext, useReducer, useMemo

import React, { useReducer, createContext, useMemo } from "react";
import Form from "./Form";
import MineTable from "./MineTable";

export const CODE = {
  MINE: -7, //지뢰
  NORMAL: -1, //정상 칸
  QUESTION: -2, //물음표
  FLAG: -3, //깃발
  QUESTION_MINE: -4, //물음표 자리가 지뢰일 경우
  FLAG_MINE: -5, //깃발 자리가 지뢰일 경우
  CLICKED_MINE: -6, //지뢰를 클릭했을 경우
  OPENED: 0, //open된 칸, 0 이상이면 다 opened
};

export const TableContext = createContext({
  //초깃값 설정
  tableData: [], //배열모양
  halted: true,
  dispatch: () => {}, //함수모양
});

const initialState = {
  tableData: [],
  timer: 0,
  result: "",
  halted: true, //true일 경우, 게임 stop
};

//지뢰판 만들기
const plantMine = (row, cell, mine) => {
  const candidate = Array(cell * row)
    .fill()
    .map((item, i) => i); //0-99 숫자 중에서
  const mixData = [];
  while (candidate.length > row * cell - mine) {
    //지뢰 갯수 만큼 랜덤으로 추출
    const chosen = candidate.splice(
      Math.floor(Math.random() * candidate.length),
      1
    )[0];
    mixData.push(chosen);
    //console.log("mixData", mixData);
  }
  //2차원 배열 지뢰판 세팅
  const data = [];
  //판 만들고 정상 칸 심기
  for (let i = 0; i < row; i++) {
    const rowData = [];
    data.push(rowData);
    for (let j = 0; j < cell; j++) {
      rowData.push(CODE.NORMAL); //정상 row*cell 칸
    }
  }
  //2차원 배열에 지뢰심기
  for (let k = 0; k < mixData.length; k++) {
    const ver = Math.floor(mixData[k] / cell);
    const hor = mixData[k] % cell;
    data[ver][hor] = CODE.MINE; //지뢰심기
  }
  console.log(data);
  return data;
};

export const START_GAME = "START_GAME";
export const OPEN_CELL = "OPEN_CELL";
export const CLICK_MINE = "CLICK_MINE";
export const FLAG_CELL = "FLAG_CELL";
export const QUESTION_CELL = "QUESTION_CELL";
export const NORMAL_CELL = "NORMAL_CELL";

const reducer = (state, action) => {
  switch (action.type) {
    case START_GAME:
      return {
        ...state,
        tableData: plantMine(action.row, action.cell, action.mine), //plantMine 지뢰심기 함수
        halted: false,
      };

    case OPEN_CELL: {
      //클릭하여 칸 열기
      //불변성 떄문에 추가 코드
      const tableData = [...state.tableData]; //tableData 얕은 복사
      tableData[action.row] = [...state.tableData[action.row]];
      tableData[action.row][action.cell] = CODE.OPENED; //클릭한 셀이 CODE.OPENED로 바뀜
      return {
        ...state,
        tableData,
      };
    }

    case CLICK_MINE: {
      const tableData = [...state.tableData]; //tableData 얕은 복사
      tableData[action.row] = [...state.tableData[action.row]];
      //클릭한 셀이 CODE.OPENED로 바뀜
      tableData[action.row][action.cell] = CODE.CLICKED_MINE; 
      return {
        ...state,
        tableData,
        halted: true,
      };
    }

    //flag cell -> question cell -> normal cell
    case FLAG_CELL: {
      //지뢰 있는 칸 없는 칸 분기처리
      const tableData = [...state.tableData]; //tableData 얕은 복사
      tableData[action.row] = [...state.tableData[action.row]];
      if (tableData[action.row][action.cell] === CODE.MINE) {
        //지뢰 있는 칸
        tableData[action.row][action.cell] = CODE.FLAG_MINE;
      } else {
        //지뢰 없는 칸
        tableData[action.row][action.cell] = CODE.FLAG;
      }
      return {
        ...state,
        tableData,
      };
    }

    case QUESTION_CELL: {
      //지뢰 있는 칸 없는 칸 분기처리
      const tableData = [...state.tableData]; //tableData 얕은 복사
      tableData[action.row] = [...state.tableData[action.row]];
      if (tableData[action.row][action.cell] === CODE.FLAG_MINE) {
        //깃발, 지뢰 있는 칸
        tableData[action.row][action.cell] = CODE.QUESTION_MINE;
      } else {
        //지뢰만 있는 칸
        tableData[action.row][action.cell] = CODE.QUESTION;
      }
      return {
        ...state,
        tableData,
      };
    }

    case NORMAL_CELL: {
      //지뢰 있는 칸 없는 칸 분기처리
      const tableData = [...state.tableData]; //tableData 얕은 복사
      tableData[action.row] = [...state.tableData[action.row]];
      if (tableData[action.row][action.cell] === CODE.QUESTION_MINE) {
        //물음표, 지뢰 있는 칸
        tableData[action.row][action.cell] = CODE.MINE;
      } else {
        //지뢰만 있는 칸
        tableData[action.row][action.cell] = CODE.NORMAL;
      }
      return {
        ...state,
        tableData,
      };
    }

    default:
      return state;
  }
};

const MineSearch = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  //value useMemo로 감싸 리랜더링 최적화
  //dispatch는 항상 같은 값이므로 deps에 넣지 않음
  const { tableData, halted, timer, result } = state;
  const value = useMemo(
    () => ({ tableData: tableData, halted: halted, dispatch }),
    [tableData, halted]
  );

  return (
    //랜더링 할때마다 매번 value 객체가 새롭게 리렌더링 되므로 하위 컴포넌트도 계속 
    //리렌더링되는 문제 발생 -> useMemo로 캐싱처리
    //value = 하위에 전달하는 데이터
    //<TableContext.Provider value={{ tableData: state.tableData, dispatch }}>
    <TableContext.Provider value={value}>
      <Form />
      <div>{timer}</div>
      <MineTable />
      <div>{result}</div>
    </TableContext.Provider>
  );
};

export default MineSearch;

 

Form.jsx 

context API 설정

import React, { useState, useCallback, useContext } from "react";
import { TableContext } from "./MineSearch";

const Form = () => {
  const [row, setRow] = useState(10); //가로-줄
  const [cell, setCell] = useState(10); //세로-칸
  const [mine, setMine] = useState(20); //지뢰 갯수
  //const value = useContext(TableContext);
  //value.dispatch = useContext(TableContext);
  const { dispatch } = useContext(TableContext); //구조분해 dispatch 접근

  const onChangeRow = useCallback((e) => {
    setRow(e.target.value);
  }, []);

  const onChangeCell = useCallback((e) => {
    setCell(e.target.value);
  }, []);

  const onChangeMine = useCallback((e) => {
    setMine(e.target.value);
  }, []);

  const onClickBtn = useCallback(() => {
    //context API 적용
    dispatch({ type: "START_GAME", row, cell, mine });
  }, [row, cell, mine]);

  return (
    <div>
      <input
        type="number"
        placeholder="가로"
        value={row}
        onChange={onChangeRow}
      />
      <input
        type="number"
        placeholder="세로"
        value={cell}
        onChange={onChangeCell}
      />
      <input
        type="number"
        placeholder="지뢰"
        value={mine}
        onChange={onChangeMine}
      />
      <button onClick={onClickBtn}>시작</button>
    </div>
  );
};

export default Form;

 

MineTable.jsx

import React, { useContext } from "react";
import { TableContext } from "./MineSearch";
import MineTr from "./MineTr";

const MineTable = () => {
  const { tableData } = useContext(TableContext); //value.tabeData를 구조분해
  return (
    <div>
      <table>
        <tbody>
          {Array(tableData.length)
            .fill()
            .map((tr, i) => (
              <MineTr rowIndex={i} />
            ))}
        </tbody>
      </table>
    </div>
  );
};

export default MineTable;

 

MineTr.jsx

import React, { useContext } from "react";
import { TableContext } from "./MineSearch";
import MineTd from "./MineTd";

const MineTr = ({ rowIndex }) => {
  const { tableData } = useContext(TableContext);
  return (
    <tr>
      {tableData[0] && //tableData[0]이 undefined일 경우 대비
        Array(tableData[0].length)
          .fill()
          .map((td, i) => <MineTd rowIndex={rowIndex} cellIndex={i} />)}
    </tr>
  );
};

export default MineTr;

 

MineTd.jsx

지뢰판 화면 렌더링(onClick, onContextMenu 이벤트)

import React, { useCallback, useContext } from "react";
import { CODE, OPEN_CELL, TableContext } from "./MineSearch";

//지뢰판 디자인
const getTdStyle = (code) => {
  switch (code) {
    case CODE.NORMAL:
    case CODE.MINE:
      return {
        background: "#444",
      };
    case CODE.CLICKED_MINE:
    case CODE.OPENED:
      return {
        background: "#fff",
      };
    case CODE.FLAG_MINE:
    case CODE.FLAG:
      return {
        background: " pink",
      };
    case CODE.QUESTION_MINE:
    case CODE.QUESTION:
      return {
        background: "aqua",
      };

    default:
      return {
        background: "#fff",
      };
  }
};

const getTdText = (code) => {
  switch (code) {
    case CODE.NORMAL:
      return "";
    case CODE.MINE:
      return "X";
    case CODE.CLICKED_MINE:
      return "펑";
    case CODE.FLAG_MINE:
    case CODE.FLAG:
      return "!";
    case CODE.QUESTION_MINE:
    case CODE.QUESTION:
      return "?";
  }
};

//1. tableData는 useContext로 받기
//2. 칸, 줄 위치는 상위 컴포넌트로 부터 props로 받음
const MineTd = ({ rowIndex, cellIndex }) => {
  const { tableData, dispatch, halted } = useContext(TableContext);

  const onClickTd = useCallback(() => {
    if (halted) {
      return;
    }
    //데이터별로 구별
    switch (tableData[rowIndex][cellIndex]) {
      case CODE.OPENED: //이미 오픈된 칸
      case CODE.FLAG_MINE:
      case CODE.FLAG:
      case CODE.QUESTION_MINE:
      case CODE.QUESTION:
        return; //오픈 방지

      case CODE.NORMAL:
        dispatch({ type: "OPEN_CELL", row: rowIndex, cell: cellIndex });
        return;

      case CODE.MINE:
        dispatch({ type: "CLICK_MINE", row: rowIndex, cell: cellIndex });
    }
  }, [tableData[rowIndex][cellIndex], halted]);

  const onContextMenuTd = useCallback(
    (e) => {
      //마우스 오른쪽 클릭시
      e.preventDefault();
      if (halted) {
        return;
      }
      switch (tableData[rowIndex][cellIndex]) {
        case CODE.NORMAL:
        case CODE.MINE:
          dispatch({ type: "FLAG_CELL", row: rowIndex, cell: cellIndex });
          return; //swich문에 return 또는 break로 필요
        case CODE.FLAG_MINE:
        case CODE.FLAG:
          dispatch({ type: "QUESTION_CELL", row: rowIndex, cell: cellIndex });
          return;
        case CODE.QUESTION_MINE:
        case CODE.QUESTION:
          dispatch({ type: "NORMAL_CELL", row: rowIndex, cell: cellIndex });
          return;
        default:
          return;
      }
    },
    [tableData[rowIndex][cellIndex], halted]
  );

  return (
    <td
      style={getTdStyle(tableData[rowIndex][cellIndex])}
      onClick={onClickTd}
      onContextMenu={onContextMenuTd}
    >
      {getTdText(tableData[rowIndex][cellIndex])}
    </td>
  );
};

export default MineTd;
반응형

댓글