My Boundary As Much As I Experienced

리액트로 지뢰찾기 구현 과정 3. 게임 플레이 구현 (타일 상태 바꾸기, 클릭 시 열릴 수 있는 타일 다 열기 with BFS) 본문

Projects/etc.

리액트로 지뢰찾기 구현 과정 3. 게임 플레이 구현 (타일 상태 바꾸기, 클릭 시 열릴 수 있는 타일 다 열기 with BFS)

Bumang 2024. 4. 2. 01:44

이전 포스팅엔 타일맵 생성 로직 지뢰 뿌리기를 어떻게 구현하였는지에 대해 설명하였다.

이번 포스팅에선 게임 플레이 시 타일의 일반 상태 -> 깃발 상태 -> 물음표 상태로 전환하는지와 

어떻게 빈 타일들을 모두 열고, 지뢰 근처의 타일에서 탐색을 멈추게 했는지를 설명하겠다.

 

타일 상태 바꾸기 로직 (기본 - 깃발 - 물음표 - 게임오버  - ...)

이전 포스팅에서 소개한 바와 같이

아래 코드처럼 각각의 타일들은 TileType의 객체로 구성되어 있으며 2차원 배열을 구성하고 있다.

그리고 이를 Tile컴포넌트로 만들기 위해 map메소드로 전개한다.

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


// X가 8, Y가 8이라면... (각 객체는 TileType이다.)
[
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
  [ { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... }, { ... } ],
]

 

Tile 컴포넌트의 로직은 아래와 같다.

 

1. props로 item(현재 tile정보), tileMapArr(전체 배열), onSetTileMap(전체 배열 수정 함수),

rowIndex(Y인덱스), colIndex(X인덱스) 등을 받는다.

 

2. 그리고 이를 그대로 useTileSwitch라는 훅에 제공한다.

useTileSwitch는 item, tileMapArr, onSetTileMap, ... 등 제공 받은 인자들을 잘 편집해서 클릭 시 맵 탐색, 왼쪽 클릭, 오른쪽 클릭등을 모두 처리해준다. 그리고 결과적으로 전체 배열을 수정해준다.

// Tile 컴포넌트
const Tile = ({ item, tileMapArr, onSetTileMap, rowIndex, colIndex }: TileProps) => {
  // useTileSwitch
  const { tileLeftClickHandler, tileRightClickHandler } = useTileSwitch({ item, tileMapArr, onSetTileMap, rowIndex, colIndex });
  
  // item을 구조분해 할당해서 isFlagged, isMined, isOpened, ...등 현재 상태를 꺼낸다.
  const { isFlagged, isMined, isOpened, isQuestioned, mineNearby } = item;
  ...

 

useTileSwitch의 로직은 아래와 같다. 지금보니 이 핸들러들도 조금 더 분리해줘야할거 같다..

// useTileSwitch
const useTileSwitch = ({ item, tileMapArr, onSetTileMap, rowIndex, colIndex }: useTileSwitchProps) => {

  // 여러 훅들을 호출하고 구조분해할당하는 코드는 생략하겠다.
  ...

  // 왼쪽 클릭 핸들러
  const tileLeftClickHandler = (e: React.MouseEvent) => {
    e.preventDefault();

    // 게임오버상태, 성공상태, 이미 열렸을 때, 깃발 상태일 때 이벤트 종료
    if (
      currentPlayingState === "gameOver" || //
      currentPlayingState === "success" ||
      isOpened ||
      isFlagged
    ) {
      return;
    }

    // stale이면 게임스타트
    if (currentPlayingState === "stale") {
	  ...
    }

    const copy = [...tileMapArr];

    // 지뢰를 밟았을 때
    if (isMined) {
	  ...
    }

    detectByBfs(rowIndex, colIndex, copy); // 맵탐색 함수 (copy를 수정한다)
    onSetTileMap(copy); // 수정된 copy로 타일맵을 바꿔줌
    playSoundHandler("soundLeftClick"); // 깨알같은 sound 재생 함수
  };



  // 오른쪽 클릭 핸들러
  const tileRightClickHandler = (e: React.MouseEvent) => {
    e.preventDefault();

    // 게임오버상태, 성공상태 혹은 이미 열렸을 때 이벤트 종료
    if (
      currentPlayingState === "gameOver" || //
      currentPlayingState === "success" ||
      currentPlayingState === "stale" ||
      isOpened
    ) {
      return;
    }

    const copy = [...tileMapArr];

    // 일반 -> 깃발 -> 물음표 -> 일반 ... 전환하기
    if (isStaled) {
      copy[rowIndex][colIndex].isStaled = false;
      copy[rowIndex][colIndex].isFlagged = true;
      playSoundHandler("soundFlag");
    } else if (isFlagged) {
      copy[rowIndex][colIndex].isFlagged = false;
      copy[rowIndex][colIndex].isQuestioned = true;
      playSoundHandler("soundQuestion");
    } else if (isQuestioned) {
      copy[rowIndex][colIndex].isQuestioned = false;
      copy[rowIndex][colIndex].isStaled = true;
    }
    onSetTileMap(copy); // 수정된 타일맵 반영
  };

  return { tileLeftClickHandler, tileRightClickHandler };
};

export default useTileSwitch;

 

 

3. 

그리고 현재 플레이 상태를 불러와서,

현재 상태가 플레이일 때 isFlagged라면, isMined라면, ... 조건등에 따라 컴포넌트들을 다르게 return해준다.

 const { currentPlayingState } = usePlayingSwitch();

  const tileColor = pickTileColor(mineNearby);

  // 현재 플레이상태가 gameOver이고, 지뢰가 있는 타일이었으면? 
  // => 지뢰가 어딨었는지 정답을 알려줌
  if (currentPlayingState === "gameOver" && isMined) {
    return (
      <TileContainer $color={tileColor} $isOpened={isOpened} onClick={tileLeftClickHandler} onContextMenu={tileRightClickHandler}>
        <BombIcon className="icon" />
      </TileContainer>
    );
  }

  // 현재 플레이상태가 success이고, 지뢰가 있는 타일이었으면?
  // => 지뢰가 있는 곳에 깃발이 꽃혀 보이게 함
  if (currentPlayingState === "success" && isMined) {
    return (
      <TileContainer $color={tileColor} $isOpened={isOpened} onClick={tileLeftClickHandler} onContextMenu={tileRightClickHandler}>
        <FlagIcon className="icon" />
      </TileContainer>
    );
  }

  // 깃발이 꽃혀있다면
  if (isFlagged) {
    return (
      <TileContainer $color={tileColor} $isOpened={isOpened} onClick={tileLeftClickHandler} onContextMenu={tileRightClickHandler}>
        <FlagIcon className="icon" />
      </TileContainer>
    );
  }

  // 물음표가 찍혀있다면
  if (isQuestioned) {
    console.log(isFlagged, isMined, isOpened, isQuestioned, mineNearby, "?ITEM");
    return (
      <TileContainer $color={tileColor} $isOpened={isOpened} onClick={tileLeftClickHandler} onContextMenu={tileRightClickHandler}>
        <QuestionIcon className="icon" />
      </TileContainer>
    );
  }

  // 열렸는데 지뢰가 없고, 근처 지뢰가 0이라면
  if (isOpened && !isMined && mineNearby !== 0) {
    return (
      <TileContainer $color={tileColor} $isOpened={isOpened} onClick={tileLeftClickHandler} onContextMenu={tileRightClickHandler}>
        {mineNearby}
      </TileContainer>
    );
  }

  // 아직 안 열렸다면 default return
  return <TileContainer $color={tileColor} $isOpened={isOpened} onClick={tileLeftClickHandler} onContextMenu={tileRightClickHandler}></TileContainer>;
};

export default Tile;

 

이렇게 타일들이 바뀌는 것을 구현하였다.

그런데 지금 포스팅하며 다시 보니 useTileSwitch훅이 불필요하게 많이 반복되었다는 생각이 든다.

현재 모든 Tile마다 useTileSwitch 훅을 들고 있는데,

사실 전체맵을 수정하는 useTileSwitch는 상위 컴포넌트인 TileMap에만 존재하는게 좋을거 같다.

Tile은 onClick으로 좌표값만 상위 컴포넌트로 쏴주고

그 이외의 로직은 들고 있지 않은 방향으로 수정하는게 더 좋을거 같다.

나중에 리팩토링으로 수정해봐야겠다.

 

 

 

타일맵 탐색하며 열 수 있는 타일 다 여는 로직 (BFS 활용)

지뢰찾기 게임을 진행하려면

특정 타일을 클릭했을 때 그 좌표 근처의 열릴 수 있는 모든 타일을 열어야한다.

 

이런 2차원 배열을 순회하면 특정 로직들에 충족하는지 알아내려면

깊이우선탐색(dfs)이나, 너비우선탐색(bfs)를 써야한다.

 

이때 나는 너비우선탐색을 쓰기로 결정하였다.

너비우선탐색과 깊이우선탐색의 시간복잡도는 똑같지만

보통 너비우선탐색이 더 빠른 경우가 많다.

 

기본적으로 깊이우선탐색은 완전 탐색처럼 모든 경우의 수를 깊이 들어가며 다 따져보는데

bfs는 근처의 순회할 수 있는 녀석만 골라서 다음 탐색 queue에 추가하기 때문에 최단거리 탐색에 유리하기 때문이다.

 

또한 지뢰찾기는 100x100사이즈의 타일맵까지 만들 수 있는데

이걸 깊이우선 탐색을 썼을 때 얼마나 재귀 함수가 많이 쌓일까?

이건 분명 callstack overflow가 발생할 것이다.

 

하여튼 bfs로 구현하기로 했고, 이제 남은건

어떤 타일이 열려야하는 타일이고, 어떤 타일이 열리지 말아야하는 타일인지를 알아야 하는 것이다.

 

이를 알기위해서 지뢰찾기 게임을 관찰해보았다.

1. 지뢰가 없는 타일들은 열린다.

2. 지뢰가 없어도 근처에 지뢰가 있는 타일이라면 탐색을 멈춘다.

라는걸 알 수 있다.

 

나는 item의 속성으로 근처에 지뢰가 몇 개가 있는지를 mineNearby로 정의하였고,

mineNearby가 0이라면 계속 순회하고 mineNearby가 있다면 순회를 멈추게 하는 로직을 구현하였다.

 

아래는 실제 구현 코드이다. detectByBfs라는 이름을 지었다.

import { TileType } from "@/types/tile";
import { Queue } from "./queue"; 
// queue자료구조를 하나 만들었다. 
// 자바스크립트 배열은 shift 후 재조정의 시간이 O(N)이 추가적으로 걸리기 때문에
// 성능에 민감한 부분은 Queue를 직접 구현하거나 다른 라이브러리를 쓰는 것이 좋기 때문이다.

const detectByBfs = (Y: number, X: number, tileMapArr: TileType[][]) => {
  const queue = new Queue();

  // 물음표일때는 클릭 가능하다. 그래서 물음표였으면 false로 물음표 상태를 꺼준다.
  tileMapArr[Y][X].isQuestioned = false; 
  queue.enqueue([Y, X]);
  // 열 수 있는 타일들을 Queue에 넣는 로직을 수행할건데
  // 이 Queue가 0이 되면 순회를 멈춘다.
  while (queue.getLength() !== 0) { 
    const [curY, curX] = queue.dequeue(); // queue에서 타일을 하나 꺼낸다.

    tileMapArr[curY][curX].isOpened = true; // 첫 타일의 isOpened를 true로 바꿔준다.
    if (tileMapArr[curY][curX].mineNearby > 0) return; // mineNearby가 0보다 크다면 return

    // 만약 위 코드에서 return이 안 됐다면 근처에 지뢰가 없는 타일이니,
    // 현재 타일 기준 동서남북 8방향을 열어준다.
    for (const nxt of [
      [curY - 1, curX],
      [curY + 1, curX],
      [curY, curX - 1],
      [curY, curX + 1],
      [curY - 1, curX - 1],
      [curY - 1, curX + 1],
      [curY + 1, curX - 1],
      [curY + 1, curX + 1],
    ]) {
      if (
        tileMapArr[nxt[0]] !== undefined && // 범위가 맵 바깥이 아니라면
        tileMapArr[nxt[0]][nxt[1]] !== undefined && // 범위가 맵 바깥이 아니라면
        tileMapArr[nxt[0]][nxt[1]].isMined === false && // 지뢰가 없다면
        tileMapArr[nxt[0]][nxt[1]].isOpened === false // 열리지 않았다면
      ) {
        tileMapArr[nxt[0]][nxt[1]].isOpened = true; // 해당 타일을 열어준다.
        if (tileMapArr[nxt[0]][nxt[1]].mineNearby === 0) {
          queue.enqueue(nxt as number[]);
        }
      }
    }
  }

  return;
};

export default detectByBfs;

 

이렇듯 근처 지뢰가 있는지 없는지 여부로 너비우선탐색을 하는 코드로 게임을 돌려보았다.

그리고 아래는 그 결과다. 잘 된다! 이 로직 하나만으로 게임이 잘 구현된다는게 되게 신기한 경험이었다.

또한 꽤 많은 타일을 설정해놓고 지뢰는 꼴랑 2~3개인 극단적인 상황을 설정해도 잘 돌아간다.

 

 

 

오늘은 타일 상태 변화와 맵 탐색 로직에 대해 설명하였는데,

내일은 이제 사운드라던가 신경 쓴 사소한 디테일들에 대해 정리해보겠다👋