(2022/02/14 랭크기능 추가)[React Numble Challenge] 다른 색깔 찾기 게임 만들기

정현수·2022년 2월 8일
122

프로젝트

목록 보기
1/2
post-thumbnail

구현 결과

게임하러가기: https://find-different-color.vercel.app/
깃허브 레파지토리: https://github.com/junghyeonsu/find-different-color

(⭐ 2022/02/14 랭크기능 추가 ⭐)

메인 페이지에 랭크 보기 버튼을 추가해서 랭크 페이지를 추가했습니다.

처음에 게임 시작 버튼을 클릭하게 되면 닉네임을 입력받습니다.
닉네임은 빈 공백만 아니면 되고, 닉네임은 세션(Session storage 사용)에서 유지됩니다.

게임이 종료가 되면 자동적으로 닉네임, 스테이지, 점수로 기록이 되며
랭크 페이지에서 랭킹을 확인할 수 있습니다.

랭킹은 1등에서 100등까지만 확인할 수 있도록 했습니다!
데이터베이스는 firestore를 사용했습니다.

랭킹 기준은 단계(stage)를 우선적으로 내림차순하고,
단계가 같다면 점수(Point)를 내림차순으로 랭킹이 매겨집니다.

다른분들과 경쟁해보세요!

🕹️ Numble Challenge

챌린지 내용 링크

기간: 2/4 - 2/13

이 챌린지는 React를 더 React스럽게 사용하고 싶은 0~1년차 React 기반 프론트엔드 개발자에게 적합해요.

이 챌린지에서 우리는 Context, Redux, Recoil 등 상태관리 라이브러리를 사용하지 않고 다른 색깔 찾기 게임을 제작할 거예요.

이 챌린지를 통해 우리는 이 챌린지를 통해 우리는 요구사항에 따라 적절한 단위로 component를 나눠볼 수 있고, 어느 component가 어느 state를 가지고 있어야하는가에 대해 실전적으로 익힐 수 있으며, 적절한 prop을 사용해 탄력적인 component 작성에 대해서도 연습할 수 있어요.

요구 사항

📌 참가 동기

4학년이 끝나고 취준을 하느라 개발에 많은 시간을 쓰지 못하고, CS공부나 알고리즘을 푸는데에 시간을 보내고 있었다. 그러다가 친구의 추천으로 Numble Challenge에 대해서 알게되었고, 너무 재미있어 보여서 그냥 생각도 안하고 바로 신청했다.

📌 사용한 기술

  • TypeScript (language)
  • create-react-app (react)
  • react-router-dom (router)
  • styled-components (CSS in JS)
  • react-animated-numbers (number animation)
  • react-responsive-modal (Modal component)
  • Eslint & Prettier (linter & formatter)
  • Vercel (deploy)

📌 프로토타입

디자인을 좀 예쁘게 하고 싶었기에 Pigma 툴을 이용해서 간단히 프로토타입을 제작하고 들어갔다.
프로토타입이 있는 것과 없는 것의 개발 속도 차이는 엄청나다.
프로토타입이 있으면 그대로 개발만 진행하면 되지만, 없으면 개발을 하면서 스타일도 신경써야 하기 때문에 멀티태스킹을 해야한다.

📌 폴더 구조

📦src
 ┣ 📂components
 ┃ ┣ 📂Board
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┣ 📜styled.ts
 ┃ ┃ ┗ 📜types.ts
 ┃ ┣ 📂Card
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┣ 📜styled.ts
 ┃ ┃ ┗ 📜types.ts
 ┃ ┣ 📂Footer
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┗ 📜styled.ts
 ┃ ┣ 📂Modal
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┣ 📜styled.ts
 ┃ ┃ ┗ 📜types.ts
 ┃ ┣ 📂Point
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┣ 📜styled.ts
 ┃ ┃ ┗ 📜types.ts
 ┃ ┣ 📂Stage
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┣ 📜styled.ts
 ┃ ┃ ┗ 📜types.ts
 ┃ ┣ 📂Timer
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┣ 📜styled.ts
 ┃ ┃ ┗ 📜types.ts
 ┃ ┗ 📜index.ts
 ┣ 📂hooks
 ┃ ┣ 📜usePoint.ts
 ┃ ┣ 📜useStage.ts
 ┃ ┗ 📜useTimer.ts
 ┣ 📂pages
 ┃ ┣ 📂Home
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┗ 📜styled.ts
 ┃ ┣ 📂Play
 ┃ ┃ ┣ 📜index.tsx
 ┃ ┃ ┗ 📜styled.ts
 ┃ ┗ 📜index.ts
 ┣ 📂utils
 ┃ ┗ 📜JSUtility.ts
 ┣ 📜App.tsx
 ┣ 📜GlobalStyles.ts
 ┣ 📜constants.ts
 ┗ 📜index.tsx

📌 주요 로직

타이머

function useTimer(): TimerHookProps {
  const [time, setTime] = useState<number>(INITIAL_TIME);
  const [animationActive, setAnimationActive] = useState<boolean>(false);
  const intervalRef: { current: NodeJS.Timeout | null } = useRef(null);

  const startTimer = useCallback(() => {
    if (intervalRef.current !== null) return;

    intervalRef.current = setInterval(() => {
      setTime(time => time - 1);
    }, ONE_SECOND);
  }, []);

  const stopTimer = useCallback(() => {
    if (intervalRef.current === null) return;

    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }, []);

  const resetTimer = useCallback(() => {
    setTime(INITIAL_TIME);
  }, []);

  const minusTime = useCallback(() => {
    setTime(time - 3);
    setAnimationActive(true);
    setTimeout(() => {
      setAnimationActive(false);
    }, 100);
  }, [time]);

  return { time, animationActive, startTimer, stopTimer, resetTimer, minusTime };
}

게임에서 15초의 시간이 주어지고 0초가 되면 게임이 끝난다.
그리고 틀린 카드를 클릭 시 3초가 줄어드는 함수가 필요했다.
useTimer 훅을 생성해서 timer와 관련된 함수들을 관리했다.

타이머를 useRef를 사용해서 구현하는 것이 제일 성능상, 그리고 사이드이펙트를 제거할 수 있는 좋은 방법 같았다. 타이머 구현 관련 자료를 참고해서 개발하였다.

그리고 animationActive 상태 값은 타이머 Bar가 5초 이하로 떨어지면 Bar가 흔들리는 애니메이션을 추가했는데, 해당 애니메이션을 넣기 위해서 다음과 같이 작성했다.

  animation: ${(props: ContainerProps) =>
    (props.time <= 5 || props.active) && `shake 0.3s infinite linear`};

위의 코드는 styled-components 라이브러리를 사용한 코드다.
우선 animation 속성에 infinite를 주어서 계속 동작하도록 한다.
그리고 animationActive 상태 값이 true이거나, 5초 이하일 때만 애니메이션이 작동하도록 한다.

애니메이션은 keyframes를 사용했고, 아래와 같이 작성했다.

  @keyframes shake {
    0% {
      transform: translate(1px, 1px) rotate(0deg);
    }
    10% {
      transform: translate(-1px, -2px) rotate(-1deg);
    }
    20% {
      transform: translate(-3px, 0px) rotate(1deg);
    }
    30% {
      transform: translate(3px, 2px) rotate(0deg);
    }
    40% {
      transform: translate(1px, -1px) rotate(1deg);
    }
    50% {
      transform: translate(-1px, 2px) rotate(-1deg);
    }
    60% {
      transform: translate(-3px, 1px) rotate(0deg);
    }
    70% {
      transform: translate(3px, 1px) rotate(-1deg);
    }
    80% {
      transform: translate(-1px, -1px) rotate(1deg);
    }
    90% {
      transform: translate(1px, 2px) rotate(0deg);
    }
    100% {
      transform: translate(1px, -2px) rotate(-1deg);
    }
  }

이렇게 작성하면 이 애니메이션을 동작시키고 싶을 때만 animationActive 상태 값을 잠시 true로 바꿨다가, setTimeout을 이용해서 다시 false로 바꾸면 된다.

정답이 아닌 색깔 카드를 클릭했을 때, 한번 흔들리는 애니메이션이 아래와 같이 작성되었다.

  const minusTime = useCallback(() => {
    setTime(time - 3);
    setAnimationActive(true); // 틀렸을 때, true 바꿨다가
    setTimeout(() => {		  // setTimeout을 이용해서 0.1초뒤에 바로 꺼버린다.
      setAnimationActive(false);
    }, 100);
  }, [time]);

색깔 뽑기 로직

해당 프로젝트에서 제일 시간이 많이 걸렸던 부분이다.

우선 두 가지가 관건이었다.

  • 어떻게 색깔을 랜덤으로 뽑을 건지?
  • 어떻게 랜덤으로 뽑힌 색과 다른 색을 뽑을 건지?

첫번째로 색깔을 랜덤으로 뽑는 로직은 rgb 색상 표기법을 생각했다.
CSS color property에 rgb(255, 255, 255)와 같이 색깔을 표시할 수 있는데
0에서 255까지 랜덤 숫자를 뽑아서 rgb에 넣어주면 되는 것이었다.

  const pickRandomColor = useCallback(() => Math.floor(Math.random() * 256), []);

Math.random() 함수가 0에서 1미만의 수를 랜덤으로 뽑는 것이다.
곱하기 256을 하면 0에서 256미만의 수를 뽑는다.
그래서 Math.floor로 내림 연산을 해주면 0에서 255까지의 정수를 잘 뽑는다.

  const difference = useMemo(() => (100 - stage * 2 > 0 ? 100 - stage * 2 : 2), [stage]);

  const pickAnswerColor = useCallback(
    color => (color > 100 ? color - difference : color + difference),
    [difference],
  );

정답(클릭해야 하는) 색깔을 뽑는 것은 위와 같다.
우선 difference 변수를 생성해서 stage에 따라서 변하게 했다.
스테이지가 올라갈수록 difference의 값이 작아진다.

그리고 뽑아진 랜덤 색상에 difference 만큼 빼거나 더해서 색차이를 만들었다.
그래서 색상을 뽑는 함수는 아래와 같이 최종적으로 작성되었다.

  const colors = useMemo((): { wrong: string; answer: string } => {
    const red = pickRandomColor();
    const green = pickRandomColor();
    const blue = pickRandomColor();

    return {
      wrong: `rgb(${red}, ${green}, ${blue})`,
      answer: `rgb(${pickAnswerColor(red)}, ${pickAnswerColor(green)}, ${pickAnswerColor(blue)})`,
    };
  }, [pickRandomColor, pickAnswerColor]);

스테이지가 진행되면 될수록 색의 차이가 점점 줄어든다.
그리고 차이가 2보다 작아지면 차이는 2로 고정된다. (rgb 2가 차이나는 것을 구분할 수 있나요?...)

📌 어려웠던 점

게임 보드를 보여주는 것이 어려웠다.
스테이지가 올라갈수록 Card의 개수, 크기 그리고 정답 Card의 Index가 랜덤으로 계속 변해야 했기 때문에 조금 까다로웠다.

  const boardRow = useMemo(() => Math.round((stage + 0.5) / 2) + 1, [stage]);
  const cardAmount = useMemo(() => boardRow ** 2, [boardRow]);
  const cardSize = useMemo(() => BOARD_SIZE / (cardAmount / boardRow), [cardAmount, boardRow]);
  const answerCardIndex = useMemo(
    () => Math.floor(Math.random() * (Math.round((stage + 0.5) / 2) + 1) ** 2),
    [stage],
  );

  const cards = useMemo(
    () =>
      Array.from(Array(cardAmount), (_, index) =>
        answerCardIndex === index ? (
          <Card
            onClick={handleAnswerCardClick}
            color={`${colors.answer}`}
            size={cardSize}
            key={index}
          />
        ) : (
          <Card
            onClick={handleWrongCardClick}
            color={`${colors.wrong}`}
            size={cardSize}
            key={index}
          />
        ),
      ),
    [
      cardAmount,
      answerCardIndex,
      handleAnswerCardClick,
      colors.answer,
      colors.wrong,
      cardSize,
      handleWrongCardClick,
    ],
  );

Card 컴포넌트를 만들어서 props로 클릭 이벤트, 색깔, 크기를 받게 했다.
color는 위에서 설명했고, size와 Card의 개수, 정답 Card의 Index는 요구사항에 맞게 프로그래밍했다.

그리고 각각의 Cardborder를 줬는데, Card의 개수가 늘어날수록 게임보드의 총 크기가 변했다.

box-sizing: border-box;

그래서 Card 컴포넌트에 box-sizing: border-box를 주어서 border가 생겨도 Card 컴포넌트의 전체 width, height는 변경되지 않도록 하였다.

추후 개선점

  • 파이어베이스로 간단히 데이터베이스를 연결해서 유저 이름을 받게하고 유저들끼리 랭킹시스템을 구현하고 싶다.
  • 위의 랭킹시스템을 구현해서 여러 게임을 만들어서 플랫폼을 만들고 싶다는 생각을 했다.. 너무 재미있을 것 같다.
  • storybook, jest를 이용해서 테스트 파이프라인을 구축하고 싶다.

마무리

데모로 보여준 링크는 스타일이 내 스타일이 아니라서 내 맘대로 바꿨다.
스타일에 관한 요구사항은 아예 없었기 때문에 내가 직접 구현했다.
예뻐야 사람들이 관심을 가지고 보고, 게임을 하기 때문에 스타일에 좀 많이 신경을 썼다.
그리고 애니메이션 같은 것들도 게임을 하는데 역동적인 느낌을 주기 위해서 노력했다.

맨날 어떤 프로젝트를 하면 배포도 못하고 열심히 구현을 했는데 뭘 했는지 모를 때가 많다.
이번 챌린지는 시작부터 배포를 해야됐고, 엄청난 기능을 구현하는 게 아니라서 짧은 시간동안 진짜 몰입해서 구현했다. 그리고 결과물도 확실해서 너무나도 만족한다.
그리고 모든 기능을 구현하는 것도 중요하지만 배포를 해서 피드백을 받는 것의 중요성을 다시금 느낀다.

이런 간단한 게임을 여러 개 만들어보고 싶다는 생각도 했다.
추후에 이런 챌린지가 있다면 꼭 다시 참여할 것 같고, 너무나 좋은 경험이었다.

profile
개인블로그를 만들었습니다. https://junghyeonsu.com/

19개의 댓글

comment-user-thumbnail
2022년 2월 8일

우와... 고수님 좋아요 꾹 누르고 갑니다! 디자인 너무 좋아요!

1개의 답글
comment-user-thumbnail
2022년 2월 10일

43스테이지까지 갔네요ㅋㅋㅋ 그 뒤는 넘 어려워요 ㅜ

1개의 답글
comment-user-thumbnail
2022년 2월 10일

와 게임 너무 재밌어요ㅋㅋㅋ 좋은글 감사합니다~

1개의 답글
comment-user-thumbnail
2022년 2월 10일


와! 잘만들었네요~ 코딩도 코딩이지만
완성도를 높이기 위한 고민들과 결과들이 잘 녹아진것 같아서 좋아요! 챌린지 좋은 결과 있길 바래요!

1개의 답글
comment-user-thumbnail
2022년 2월 12일

눈빠질거같아요 ㅋㅋ

1개의 답글
comment-user-thumbnail
2022년 2월 13일

보통 실력과 노력이 아닌데.. 고생하셨습니다 ㅎㅎ
디자인과 완성도에도 정성이 느껴져서 간만에 재밌는 구경 했어요!

1개의 답글
comment-user-thumbnail
2022년 2월 14일

와 정말 예쁘고 움직임도 달캉!하고 확 오는게 재미있어서 정신없이 했네요 47까지 갔는데 진짜 눈이 파괴당하는 줄 알았지만 승부욕이 도져서 여럿이서 난리 피웠어요 ㅋㅋㅋ 정말 멋지십니다

1개의 답글
comment-user-thumbnail
2022년 2월 14일

우와... 너무 잘만드셨습니다 :) 구조도 한번에 보기 쉬워서 좋은 개발일지인거 같습니다 :)

1개의 답글
comment-user-thumbnail
2022년 2월 14일

2등일 때 미리 박제하겠습니다.
현수님의 벽 너무 뚫기 어렵네요 ㅠㅠ

답글 달기
comment-user-thumbnail
2022년 2월 15일

랭킹이 하나도 안보여요 TT

1개의 답글