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

[react] react로 지뢰찾기 게임 만들기 ver.2

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

react 지뢰찾기

 

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

 

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

 

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

https://goddino.tistory.com/107

 

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

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

goddino.tistory.com

 

react로 구현한 지뢰찾기 게임 ver.1은 아래 포스트로 이동해주세요.

https://goddino.tistory.com/156

 

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

리액트로 구현한 지뢰찾기 게임(ver.1) 입니다. - 지뢰판 세팅 - 예제는 인프런의 제로초, "조현영"님의 강의를 들으면서 공부한 내용입니다. 순수 자바스크립트로 만든 지뢰찾기 게임은 아래 포스

goddino.tistory.com


 

구현내용

· 지뢰 갯수 표시하기
· 빈칸들 한번에 열기
· 승리 조건 체크와 타이머
· useMemo 사용하여 최적화 하기

 

지뢰 갯수 표시하기, 빈칸들 한번에 열기, 승리 조건 체크와 타이머

승리조건: 클릭한 칸이 가로 * 세로 - 지뢰개수 한 값과 같으면 승리

MineSearch.jsx

import React, { useReducer, useEffect, 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: [], //배열모양
  date: {
    //게임 시작시 data 기록
    row: 0,
    cell: 0,
    mine: 0,
  },
  result: "",
  halted: true,
  openedCount: 0,
  dispatch: () => {}, //함수모양
});

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

//지뢰판 만들기
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";
export const INCREMENT_TIMER = "INCREMENT_TIMER";

const reducer = (state, action) => {
  switch (action.type) {
    case START_GAME:
      return {
        ...state,
        data: {
          // 게임 시작 시 data 기록
          row: action.row,
          cell: action.cell,
          mine: action.mine,
        },
        openedCount: 0,
        tableData: plantMine(action.row, action.cell, action.mine), //plantMine 지뢰심기 함수
        halted: false,
        timer: 0,
      };

    case OPEN_CELL: {
      let openedCount = 0;
      //클릭하여 칸 열기
      //불변성 떄문에 추가 코드
      const tableData = [...state.tableData]; //tableData 얕은 복사
      // tableData[action.row] = [...state.tableData[action.row]];
      //위에 한줄만 새로운 객체로 만드는 것에서 모든 줄을 새로운 객체로 적용
      tableData.forEach((row, i) => {
        // 클릭한 칸 뿐 아니라 모든 칸을 새로운 객체로 만들기
        tableData[i] = [...state.tableData[i]];
      });
      const checked = [];
      const checkAround = (row, cell) => {
        //재귀함수
        //row, cell을 매개변수화 시켜서 action.row, action.cell에서 action 삭제
        // 닫힌 칸이 아니면 리턴
        if (
          [
            CODE.OPENED,
            CODE.FLAG_MINE,
            CODE.FLAG,
            CODE.QUESTION_MINE,
            CODE.QUESTION,
          ].includes(tableData[row][cell])
        ) {
          return;
        }
        // 상하좌우 없는 칸은 안 열기
        if (
          row < 0 ||
          row >= tableData.length ||
          cell < 0 ||
          cell >= tableData[0].length
        ) {
          return;
        }
        // 옆에칸 서로 검사하는 거 막아주기 - 호출 스택 터짐 방지 코드
        if (checked.includes(row + "," + cell)) {
          // 이미 검사한 칸이면
          return;
        } else {
          // 아니면 checked 배열에 넣어주기
          checked.push(row + "," + cell);
        }
        //openedCount += 1; // 칸을 열 때마다 1씩 올려주기 += 1;
        //주변칸 검사 함수
        //주변칸(around 총 8칸, 판의 모서리에 있을 경우는 윗칸, 아래칸 없음) 검사
        let around = [];
        if (tableData[row - 1]) {
          around = around.concat(
            tableData[row - 1][cell - 1],
            tableData[row - 1][cell],
            tableData[row - 1][cell + 1]
          );
        }
        around = around.concat(
          tableData[row][cell - 1],
          tableData[row][cell + 1]
        );
        if (tableData[row + 1]) {
          around = around.concat(
            tableData[row + 1][cell - 1],
            tableData[row + 1][cell],
            tableData[row + 1][cell + 1]
          );
        }
        //주변의 지뢰 갯수를 확인하여 표시
        const count = around.filter((v) =>
          [CODE.MINE, CODE.FLAG_MINE, CODE.QUESTION_MINE].includes(v)
        ).length;
        tableData[row][cell] = count;

        // 지뢰가 없으면 주변 8칸 열기
        if (count === 0) {
          const near = []; //주변칸 모으기
          if (row - 1 > -1) {
            //제일 위칸 클릭할 경우, 칸 없애주기
            near.push([row - 1, cell - 1]);
            near.push([row - 1, cell]);
            near.push([row - 1, cell + 1]);
          }
          near.push([row, cell - 1]);
          near.push([row, cell + 1]);
          if (row + 1 < tableData.length) {
            //제일 아래칸 클릭할 경우, 칸 없애주기
            near.push([row + 1, cell - 1]);
            near.push([row + 1, cell]);
            near.push([row + 1, cell + 1]);
          }
          near.forEach((n) => {
            //주변칸이 닫혀 있을 때만 열림
            if (tableData[n[0]][n[1]] !== CODE.OPENED) {
              checkAround(n[0], n[1]);
            }
          });
        }
        //if (tableData[row][cell] === CODE.NORMAL) {
        //내칸이 닫힌 칸이면 카운트 주기
        openedCount += 1; // 칸을 열 때마다 1씩 올려주기
        //}
        tableData[row][cell] = count;
      };
      checkAround(action.row, action.cell);
      let halted = false;
      let result = "";
      console.log(
        state.data.row * state.data.cell - state.data.mine,
        state.openedCount,
        openedCount
      );
      if (
        //승리 조건
        state.data.row * state.data.cell - state.data.mine ===
        state.openedCount + openedCount
      ) {
        // 승리
        halted = true; // 승리 시 게임 멈추기
        result = "승리하셨습니다!";
      }
      return {
        ...state,
        tableData,
        halted,
        result,
        openedCount: state.openedCount + openedCount,
      };
    }

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

    //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,
      };
    }

    case INCREMENT_TIMER: {
      return {
        ...state,
        timer: state.timer + 1,
      };
    }

    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]
  );

  useEffect(() => {
    let timer;
    if (halted === false) {
      // 게임 시작 시 타이머 실행
      timer = setInterval(() => {
        dispatch({ type: INCREMENT_TIMER });
      }, 1000);
    }
    return () => {
      clearInterval(timer);
    };
  }, [halted]);

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

export default MineSearch;

 

화면 결과

지뢰 찾기

 

반응형

댓글