My Boundary As Much As I Experienced

리액트로 지뢰찾기 구현 과정 4. 부가적인 요소 구현 (게임 타이머, 리셋버튼, 남은 지뢰갯수, 사운드 구현) 본문

Projects/etc.

리액트로 지뢰찾기 구현 과정 4. 부가적인 요소 구현 (게임 타이머, 리셋버튼, 남은 지뢰갯수, 사운드 구현)

Bumang 2024. 4. 2. 13:52

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

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

 

이번엔 게임의 부수적인 요소인 게임 타이머, 리셋버튼 얼굴 전환, 사운드 구현 등을 어떻게 했는지에 대해 설명하겠다.

 

레벨, 게임플레이 상태와 게임 플레이같은 뼈대가 되는 요소들이 모두 구현된 상태에선

이런 부수적인 요소는 쉽게쉽게 처리할 수 있다.

 

게임 타이머 구현 로직

아래 코드는 useTimer훅이다. 짧게 기능을 설명하자면

 

레벨 전역 상태가 playing이면 setTimeout을 시작하고,

stale일 때는 0으로 초기화한다.

succeess이거나 gameOver일때는 0으로 초기화하지 않고 그냥 멈춘다.

 

이때 페이지를 이탈하거나 모종의 이유로 재로딩이 걸린다면,

return에 클린업 함수가 작동하여 기존 setTimeout을 취소하고 다시 타이머가 시작한다.

// useTimer 훅
const useTimer = () => {
  const { currentLevel } = useLevelSwitch();
  const { currentPlayingState } = usePlayingSwitch();
  const { TITLE } = currentLevel;

  const [time, setTime] = useState(0);

  useEffect(() => {
    if (currentPlayingState === "stale") {
      setTime(0);
      return;
    } else if (currentPlayingState === "success") {
      const highscore = localStorage.getItem("highscore");
      setScore(TITLE, time, highscore);
      return;
    } else if (currentPlayingState === "gameOver") {
      return;
    }

    const interval = setInterval(() => {
      setTime((prev) => prev + 1);
    }, 1000);
    setTime((prev) => prev + 1);

    return () => clearInterval(interval);
    // eslint-disable-next-line
  }, [currentLevel, currentPlayingState]);

  const paddedTime = `${time}`.padStart(3, "0");
  return { paddedTime };
};

export default useTimer;

 

paddedTime은 게임 전광판처럼 세자리 숫자를 계속 보여주기 위해서 100의 자리 이하일 때는 앞에 0을 붙여준 숫자값이다.

이를 실제 컴포넌트에서 TimerContainer라는 jsx요소에 담았다. 

const TimerPanel = () => {
  const { paddedTime } = useTimer();

  return <TimerContainer>{paddedTime}</TimerContainer>;
};

 

 

리셋 버튼 구현

정~말정말 단순하다. 잘 만들어둔 레벨 전역 상태 훅에서 '현재 게임 플레이 상태'를 꺼낸다. 

꺼낸걸로 이모지들을 각각 다르게 설정한다.

버튼에 onClick으로 현재 게임 플레이 상태를 stale로 바꾸는 로직도 넣는다.

const RestartEmojiPanel = () => {
  const { currentPlayingState, playingSwitchHandler } = usePlayingSwitch();
  const { playSoundHandler } = useSound();

  const clickEmojiHander = () => { // 최선의 이름은 아닌듯하다.
    playSoundHandler("soundReset"); // 리셋하는 사운드를 재생하는 버튼
    playingSwitchHandler("stale"); // 플레이 상태를 stale로 바꾼다.
  };

  return (
    <RestartEmojiContainer onClick={clickEmojiHander}>
      {currentPlayingState === "stale" && "🙂"}
      {currentPlayingState === "playing" && "😀"}
      {currentPlayingState === "gameOver" && "🤯"}
      {currentPlayingState === "success" && "😎"}
    </RestartEmojiContainer>
  );
};

 

 

사운드 구현

실제로 제품에 사운드를 입혀보는건 이번이 처음이었다. (게임 개발을 하면 이런 사운드 인터랙션도 더 쓰겠지?)

하지만 사운드 구현은 그리 오래 걸리진 않았다. 음악 파일들을 고르는게 더욱 시간이 걸린거 같다.

 

그러나 한 가지 시간이 오래 걸린게 있는데 바로 오디오 파일을 어디서 로딩할 것인가?가 문제였다.

아래는 useSound훅인데, 이곳에 오디오 파일들을 로딩할지 전역에서 오디오 파일들을 로딩할지가 문제였다.

 

결과적으로 커스텀 훅 안에 이 모든 오디오 파일을 넣지않았다. 
커스텀 훅 안에 이것들을 넣었더니 커스텀 훅이 실행될 때마다 로딩이 너무 심해졌기 때문이다.

 

Tile컴포넌트 안에서 클릭 관련 커스텀 훅을 쓰고, 그 클릭 커스텀 훅 안에 useSound를 쓴 구조였다.

그런데 Tile컴포넌트는 깃발을 꽃거나 타일을 열거나 물음표 마크를 넣거나... 여러 번 state가 변하고 재렌더가 빈번히 발생한다.

그런데 재렌더가 일어날 때마다 오디오 로딩이 다시 일어나 게임이 엄청나게 버벅거린거 같다.

 

보통 unmount가 되면 가비지 컬렉터가 메모리 정리를 할 수 있게 최대한 전역 선언은 피하는 편이었는데,

이렇게 비용이 비싼 연산은 재렌더가 잘 안 되는 최상단 컴포넌트나 아예 전역으로 선언하는 것이 마음이 편하다.

 

물론 reactMemo를 통해 재생성이 안 되게 만들수도 있을거 같다.

그런데 오디오 파일 8개를 위해 useMemo를 8번 쓰기보단 그냥 전역으로 하는 것이 코드적으로 깔끔하다고 판단했다.

 

// 적당한 wav파일들을 다운 받은 것을 import해온다.
import musicFlag from "@/assets/music-flag.wav";
import musicGameOver from "@/assets/music-game-over.wav";
import musicHighScore from "@/assets/music-highscore.wav";
import musicLeftClick from "@/assets/music-leftClick.wav";
import musicQuestion from "@/assets/music-question.wav";
import musicWin from "@/assets/music-win.wav";
import musicReset from "@/assets/music-reset.wav";

// mute/unmute도 전역상태로 만들었는데 이를 제어하기 위한 전역상태 코드
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "./../store/index";
import { change } from "@/store/muteSlice";

// 생성자 함수로 Audio파일을 생성한다.
export const soundLeftClick = new Audio(musicLeftClick);
export const soundFlag = new Audio(musicFlag);
export const soundQuestion = new Audio(musicQuestion);
export const soundGameOver = new Audio(musicGameOver);
export const soundHighScore = new Audio(musicHighScore);
export const soundWin = new Audio(musicWin);
export const soundReset = new Audio(musicReset);

// 각각의 Audio 객체에 preload 속성을 설정하여 미리 로드한다. (로딩속도 개선)
soundFlag.preload = "auto";
soundGameOver.preload = "auto";
soundHighScore.preload = "auto";
soundLeftClick.preload = "auto";
soundQuestion.preload = "auto";
soundWin.preload = "auto";
soundWin.volume = 0.5;
soundReset.preload = "auto";

// 실제 훅의 시작이다.
// 1. 위에서 전역으로 생성한 오디오 파일들을 단순 반환, 2. mute/unmute 전환
// 등을 수행한다.
const useSound = () => {
  const dispatchMuteState = useDispatch();
  const currentMuteState = useSelector((state: RootState) => {
    return state.mute.value;
  });

  const muteHandler = (state: boolean) => {
    dispatchMuteState(change(state));
  };

  // playSoundHandler로 아래 string 문자열을 패러미터로 주면 switch문에서 해당하는 사운드를 재생한다.
  const playSoundHandler = (state: "soundLeftClick" | "soundFlag" | "soundQuestion" | "soundGameOver" | "soundHighScore" | "soundWin" | "soundReset") => {
    if (currentMuteState) return;
    switch (state) {
      case "soundLeftClick":
        soundLeftClick.play();
        break;
      case "soundFlag":
        soundFlag.play();
        break;
      case "soundQuestion":
        soundQuestion.play();
        break;
      case "soundGameOver":
        soundGameOver.play();
        break;
      case "soundHighScore":
        soundHighScore.play();
        break;
      case "soundWin":
        soundWin.play();
        break;
      case "soundReset":
        soundReset.play();
        break;
    }
  };

  // 현재 mute 상태, 소리 재생 핸들러, mute전환 핸들러 반환
  return { currentMuteState, playSoundHandler, muteHandler };
};

export default useSound;

 

남은 지뢰 갯수 구현

 

구현 과정은 비슷비슷하니 굳이 코드를 더 적진 않겠다.

일단 레벨 전역상태에 지뢰갯수 프로퍼티를 가져와서 그 값으로 전광판을 초기화 시켜준다.

flagged된 타일의 갯수 전역 상태를 만든다.

레벨 총 지뢰갯수에 flagged된 타일 갯수를 빼주면 된다.