My Boundary As Much As I Experienced

리액트로 지뢰찾기 구현 과정 2. 타일맵 생성, 타일 상태 설정, 게임 시작 시 지뢰 뿌리기 구현 본문

Projects/etc.

리액트로 지뢰찾기 구현 과정 2. 타일맵 생성, 타일 상태 설정, 게임 시작 시 지뢰 뿌리기 구현

Bumang 2024. 3. 31. 22:34

이전 포스팅엔 레벨 정보플레이 상태 정보를 어떻게 전역 상태로 구성하였는지에 대해 설명했다.

이번 포스팅에선 타일맵을 생성하고, 지뢰를 초기화(게임 시작)하는 로직에 대해서 설명하겠다.

 

타일맵 생성을 어떻게 할 것인가?

게임 영역의 구조는 아래 폴더구조와 같다.

TileMapPanel이 실제 지뢰찾기 맵이 있는 부분이며

레벨 별 X길이, Y길이, 지뢰 갯수들을 state로 가지고 있는 부분이기도 하다.

이 정보들을 활용하여 Tile 컴포넌트를 map 메소드로 전개해준다.

 

 

레벨과 플레이 상태에 따라 타일 맵 정의

이어서

1. 레벨과 그에 따른 지뢰 갯수, 타일 갯수들을 (유저의 선택에 따라) 어떻게 변하게 했는지

2. 게임을 시작할 때 랜덤으로 지뢰를 타일 곳곳에 매립했는지에 대해 설명하겠다.

아래는 useTileStatus라는 커스텀 훅의 내용이다.

 

useState안에 보면 generateTileMap이란 util함수가 있다.

이 유틸함수는 현재 레벨의 X, Y값을 패러미터로 받는다.

import { useMemo, useState } from "react";

import generateTileMap from "@utils/generateTileMap";
import useLevelSwitch from "@hooks/useLevelSwitch";
import usePlayingSwitch from "@/hooks/usePlayingSwitch";

const useTileStatus = () => {
  const { currentLevel } = useLevelSwitch(); // 현재 레벨
  const { currentPlayingState } = usePlayingSwitch(); // 현재 플레이 상태
  const { X, Y } = currentLevel; // 현재 레벨의 X, Y값

  // 타일맵 생성
  const [tileMapArr, setTileMapArr] = useState(generateTileMap(X, Y));

  // 레벨 전환 시 타일맵 초기화
  useMemo(() => { // useMemo로 다시 generateTileMap이 실행되지 않게 연산값 고정
    if (currentPlayingState !== "stale") return;
    setTileMapArr(generateTileMap(X, Y));
    // eslint-disable-next-line
  }, [currentLevel, currentPlayingState]);

  return { tileMapArr, setTileMapArr };
};

export default useTileStatus;

 

 

generateTileMap에 새로운 X, Y값이 올 때 마다 그에 따른 새로운 타일맵을 생성한다.

map함수를 2번 돌려 x축, y축을 모두 생성한 것을 볼 수 있다.

 

그리고 각 타일칸 마다 상태저장용 객체를 하나씩 넣어줬다.

예상할 수 있듯이, 이 정보 객체들은 게임 상태에 따라 유동적으로 상태가 변하며,

기본 상태, 깃발 상태, 열린 상태, ... 등을 표현한다.

// generateTileMap.ts
import { TileType } from "@/types/tile";

const generateTileMap = (X: number, Y: number) => {
  const arr = new Array(Y).fill([]).map(() => { // 각 Y행마다 []을 넣어준다. 
    return new Array(X).fill(null).map( // 아래와 같은 객체를 X행마다 넣어준다.
      () =>
        ({
          isOpened: false,
          isMined: false,
          isStaled: true,
          isFlagged: false,
          isQuestioned: false,
          mineNearby: 0,
        } as TileType) // TileType으로 확언해준다.
    );
  });

  return arr;
};

export default generateTileMap;

 

예를 들어 만약 X가 8, Y가 8이라면 타일 맵은 아래처럼 초기화 된다.

// 이게 TileType이다.
{
  isOpened: false, // 열렸는지 여부
  isMined: false, // 지뢰가 있는지 여부
  isStaled: true, // 기본 상태인지 여부
  isFlagged: false, // 깃발이 꽃혔는지 여부
  isQuestioned: false, // 물음표가 꽃혔는지 여부
  mineNearby: 0, // 주변에 지뢰가 몇 개 있는지 여부
}


// X가 8, Y가 8일 때 8x8 배열이 생성되며, 각각의 칸마다 TileType이 들어간다.)
[
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
]

 

 

그렇게 타일맵 배열(tileMapArr)을 가지고 실제 컴포넌트들을 렌더하게 되는 것이다.

const TileMapPanel = () => {
  const { tileMapArr, setTileMapArr } = useTileStatus();
  useCountTileMap(tileMapArr);
  useGoalSwitch();

  return (
    <TileMapContainer>
      {tileMapArr.map((row, rowIndex) => (
        <RowWrapper key={rowIndex}>
          {row.map((item, colIndex) => (
            <Tile //
              tileMapArr={tileMapArr}
              onSetTileMap={setTileMapArr}
              rowIndex={rowIndex}
              colIndex={colIndex}
              key={`${rowIndex}&${colIndex}`}
              item={item}
            />
          ))}
        </RowWrapper>
      ))}
    </TileMapContainer>
  );
};

 

 

첫 클릭 시 지뢰 생성 구현

처음엔 타일맵을 생성할 때 지뢰도 같이 랜덤으로 뿌려주려고 했다.

그런데 첫 클릭 시에는 지뢰가 터지면 안 된다는 과제 요구 조건이 있었는데,

지뢰를 게임 시작 전에 미리 뿌려놓으면 첫 클릭 시 지뢰를 클릭할 확률이 생긴다는 것이 문제였다.

첫 클릭부터 이렇게 게임 오버 안 되게 하려면 맵 생성 시 미리 지뢰를 뿌려두면 안 된다고 생각했다. (지금 와서 생각해보면 꼭 그런건 아님)

 

그래서 타일맵 생성시엔 지뢰가 없는 상태를 유지하기로 하였고

첫 클릭 시 지뢰를 뿌려주기로 하였다. 또한, 딱 첫 클릭 시 바로 옆에 지뢰가 있으면 딱 한 칸만 열리게 되는데

이것 또한 첫 시작부터 의미있는 게임플레이가 불가능한 것이다.

 

그러므로 while문에서 랜덤 좌표에 지뢰를 뿌려주는 로직을 만들었긴 한데,

클릭한 좌표와 그 근방 8방향에 지뢰가 매립되려한다면 continue를 해줘서 면제를 시켜줬다.

그 결과 첫 시작 시 게임오버가 되거나, 딱 한 칸만 열리는 불상사를 없앨 수 있었다.

// useInitializeGame
import useLevelSwitch from "@/hooks/useLevelSwitch";
import { TileType } from "@/types/tile";

interface initializeProps {
  onSetTileMap: React.Dispatch<React.SetStateAction<TileType[][]>>;
  tileMapArr: TileType[][]; // 타일맵 타입
  colIndex: number; // Y좌표
  rowIndex: number; // X좌표
}

const useIntializeGame = ({ tileMapArr, colIndex, rowIndex, onSetTileMap }: initializeProps) => {
  const { currentLevel } = useLevelSwitch(); // 현재 레벨
  const { MINE, X, Y } = currentLevel; // 지뢰 갯수, X길이, Y길이 구조분해

  const GenRandomMineHandler = () => {
    const copy = [...tileMapArr]; // 일단 초기화된 타일맵을 카피한다.

    let mined = 0; // 현재 지뢰 갯수
    const maxMineAmount = MINE; // MAX 지뢰 갯수
    while (mined < maxMineAmount) { // 현재 지뢰 갯수가 MaxMineAmount에 도달할 때까지
      const randomY = Math.floor(Math.random() * Y); // 0 ~ Y까지의 랜덤 정수 생성
      const randomX = Math.floor(Math.random() * X); // 0 ~ X까지의 랜덤 정수 생성

      const target = copy[randomY][randomX]; // 랜덤 좌표 너로 정했다!
      if (
        randomY >= rowIndex - 1 && 
        randomY <= rowIndex + 1 &&
        randomX >= colIndex - 1 &&
        randomX <= colIndex + 1
      )
        continue; // 랜덤 좌표가 범위 내에 없으면 continue
      if (target?.isMined) continue; // 지뢰가 이미 있으면 continue

      // 위 조건들을 충족했다면
      mined++; // 지뢰 갯수를 +1
      target.isMined = true; // 타겟의 isMined에 true 설정
      markNearbyAmount(randomX, randomY, copy); // 타겟 좌표 주변 8방향의 타일에 '근처 지뢰'값에 +1 해주는 함수
    }

    onSetTileMap(copy); // 지뢰 생성 후 상태 업데이트
  };

  ...

 

그리고 위의 getRandomMineHandler함수가 바로 지뢰를 랜덤으로 뿌려주는 함수이다.

getRandomMineHandler함수의 마지막 부근에 markNearbyAmount라는 함수가 있는데,

이건 바로 주변 8방향에 지뢰가 있다면 count해서 해당 타일의 정보 객체의 mineNearby값을 1씩 올려준다.

mineNearby는 게임 플레이 시 오픈된 타일의 숫자 정보가 된다.(주변에 지뢰가 얼마나 있는지.)

 // 지뢰 기준 8방향에 숫자 +1 하는 함수
  const markNearbyAmount = (randomX: number, randomY: number, copy: TileType[][]) => {
    // 진짜 말 그대로.. 8방향의 좌표의 타일 객체에 mineNearby 속성의 값 + 1을 해주는 과정이다😂
    if (copy[randomY + 1]) {
      copy[randomY + 1][randomX].mineNearby++;
    }

    if (copy[randomY - 1]) {
      copy[randomY - 1][randomX].mineNearby++;
    }

    if (copy[randomY][randomX + 1]) {
      copy[randomY][randomX + 1].mineNearby++;
    }

    if (copy[randomY][randomX - 1]) {
      copy[randomY][randomX - 1].mineNearby++;
    }

    if (copy[randomY + 1] && copy[randomY + 1][randomX + 1]) {
      copy[randomY + 1][randomX + 1].mineNearby++;
    }

    if (copy[randomY + 1] && copy[randomY + 1][randomX - 1]) {
      copy[randomY + 1][randomX - 1].mineNearby++;
    }

    if (copy[randomY - 1] && copy[randomY - 1][randomX + 1]) {
      copy[randomY - 1][randomX + 1].mineNearby++;
    }

    if (copy[randomY - 1] && copy[randomY - 1][randomX - 1]) {
      copy[randomY - 1][randomX - 1].mineNearby++;
    }
  };

  return { GenRandomMineHandler };
};

export default useIntializeGame;

 

이렇게 첫 클릭 시(게임 시작 시) 지뢰를 지정 갯수만큼 생성하는 로직까지 만들었다.

다음 글에서는 위 스크린 샷처럼 실제 타일들이 열리게 하는 로직을 설명하겠다.

 

 

추가적으로 개선할 사항

하드 레벨 까지는 '첫 클릭 시 지뢰 깔기'를 해도 렉이 안 걸리지만,

커스텀 레벨에서 최대 100x100개의 타일을 설정해버리면

조금 연산이 오래걸리는지 로딩이 오래 걸린다.

 

이걸 개선하기 위해 지뢰를 초기화 시 미리 뿌려주지만,

클릭 시 지뢰가 있다면 그 지뢰만 다른 빈 곳으로 옮겨주는 함수를 만들어서

첫 클릭 로딩을 줄이는 방안으로 개선해봐야겠다.