본문 바로가기

프론트엔드/React

노래맞히기 게임 구현하기(feat. holwer.js, classList)

  • 프로젝트: SNU FESTIVAL
  • 키워드: trigger css animation, classList, svg, holwer.js, Web Audio API
  • 상황:
    1. 페스월드 축제의 미니게임인 '노래 맞히기'는 노래의 아주 짧은 부분만 듣고 제목을 맞히는 게임이다.
    2. 키보드 조작이나 화면 터치를 통해 음악이 재생되도록 하고, 시각적인 재미를 줄 수 있도록 애니메이션도 곁들이고자 한다.

 

원하는 결과

  • 해결 과정
    1. 페이지 단에서 addEventListener로 키보드 조작을 감지하고, A를 눌렀다면 'KeyA'에 해당하는 음악과 애니메이션을 재생하면 된다.
        function reducer(state, action) {
        if (action.type === 'toggle') {
          const { key } = action;
          return { ...state, [key]: !state[key] };
        }
        throw new Error();
      }
      
      const [triggers, dispatch] = useReducer(reducer, { triggerA: false, triggerB: false, ... });
      
      // 키보드 조작
      useEffect(() => {
        const keypressFunction = (e) => {
          switch (e.code) {
            // A~O 까지만 사용
            case 'KeyA':
            ...
            case 'KeyO':
              dispatch({ type: 'toggle', key: `trigger${e.code[3]}` });
              ...
              break;
            ...
            default:
              break;
          }
        };
      
        window.addEventListener('keypress', keypressFunction);
        return () => {
          window.removeEventListener('keypress', keypressFunction);
        };
      }, []);
      
      // 참고) 화면 터치를 위한 타일
      const Tiles = useMemo(() => (
        ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O'].map((key, i) => {
          ...
          return (
            <S.Tile
              className={className}
              onTouchStart={() => {
                ...
                dispatch({ type: 'toggle', key: `trigger${key}` });
                ...
            }}
          />
        );
      })
      ), []);


    2. 특정 Key가 trigger 되면, Key에 해당하는 컴포넌트의 prop으로 trigger가 전달된다.
        <Slide3 trigger={triggers.triggerF} dispatch={dispatch} />
      <Slide2 trigger={triggers.triggerE} dispatch={dispatch} />
      <Slide1 trigger={triggers.triggerD} dispatch={dispatch} />
      
      <Flicker1 trigger={triggers.triggerA} dispatch={dispatch} />
      <Flicker2 trigger={triggers.triggerB} dispatch={dispatch} />
      <Flicker3 trigger={triggers.triggerC} dispatch={dispatch} />
      
      <BlackTriangle trigger={triggers.triggerG} dispatch={dispatch} />
      <BlackRect trigger={triggers.triggerH} dispatch={dispatch} />
      <BlackHexagon trigger={triggers.triggerI} dispatch={dispatch} />
      
      <HorizontalLine1 trigger={triggers.triggerJ} dispatch={dispatch} />
      <HorizontalLine2 trigger={triggers.triggerK} dispatch={dispatch} />
      <HorizontalLine3 trigger={triggers.triggerL} dispatch={dispatch} />
      
      <Explosion1 trigger={triggers.triggerM} dispatch={dispatch} />
      <Explosion2 trigger={triggers.triggerN} dispatch={dispatch} />
      <Explosion3 trigger={triggers.triggerO} dispatch={dispatch} />


    3. 각 컴포넌트 별로 svg의 css animation keyframe의 내용만 다르기 때문에, Horizontal2(triggerK) 컴포넌트 하나를 중심으로 설명을 진행한다.
      먼저 triggerK에 해당하는 keypress 또는 touch가 발생하면, triggerK가 true가 되면서 Horizontal2의 trigger prop도 true가 된다.
      trigger가 true가 되었을 때 useAudio custom hook을 이용하여 특정 mp3파일을 재생시킨다. 이 프로젝트에서는 크로스 브라우징을 쉽고 빠르게 대응하기 위해 howler라는 라이브러리로 Audio API를 대신했다.
        function HorizontalLine2({ trigger, dispatch, theme }) {
        const [, playAudio] = useAudio(SongFourSecond);  // 소리를 재생하는 custom hook
      
        ...
      
        useEffect(() => {
          if (trigger) {
            playAudio();  // trigger가 일어나면 음악이 재생된다.
            ...
            dispatch({ type: 'toggle', key: 'triggerK' });  // trigger를 다시 false로 바꾼다.
          }
        }, [trigger]);
        
        
      // useAudio.js
      import { useCallback, useEffect, useState } from 'react';
      import { Howl } from 'howler';
      
      function useAudio(audioFile) {
        const [audioElement, setAudioElement] = useState(null);
      
        useEffect(() => {
          // safari 크로스브라우징 이슈가 있어 최대한 많은 기기가 지원되는 옵션을 사용했다.
          const newAudioElement = new Howl({
            src: [audioFile], html5: true, mute: false, usingWebAudio: false, webAudio: false,
          });
          setAudioElement(newAudioElement);
        }, [audioFile]);
      
        const playAudio = useCallback(() => {
          if (!audioElement) return;
          if (audioElement.playing()) {
            audioElement.stop();
          }
          audioElement.play();
        }, [audioElement]);
      
        return [audioElement, playAudio];
      }
      export default useAudio;
      특정 키를 연속으로 눌러 playAudio 함수를 여러 번 실행할 경우 howler.js의 stop, play method를 이용하여 노래를 끊고 다시 재생시킬 수 있다.


    4. 음악 재생 기능을 구현했으니 이제 css 애니메이션을 구현할 차례이다.
      triggerK에 해당하는 애니메이션

      trigger 되었을 때 애니메이션을 재생하는 핵심 로직은 className을 변경하는 데 있다. css animation 속성을 class가 .active 일 때만 적용되도록 하면 HTML tag의 추가나 삭제 없이 class만 변경하여 애니메이션을 재생시킬 수 있다.

      이제 코드를 통해 추가 설명을 하겠다.
        // HorizontalLine2.js
      let cancelAnimation = { listener: null }; // setTimeout/clearTimeout 정보를 저장하기 위해 객체
      
      const flickerRect = useRef(null);
      ...
      useEffect(() => {
        flickerRect.current = document.querySelector('.HorizontalLine2'); // class를 조작할 node
      }, []);
      
      useEffect(() => {
        if (trigger) {
          playAudio(); // 음악 재생
          startAnimation(flickerRect.current, cancelAnimation); // 애니메이션 재생
          dispatch({ type: 'toggle', key: 'triggerK' });
        }
      }, [trigger]);
      
      // functions.js
      /**
       * 하나의 dom 요소에 active class 를 넣은 후 제거.
       */
      export function startAnimation(element, cancelAnimation, time) {
        clearTimeout(cancelAnimation.listener);
        element.classList.remove('active');
        const triggerReflow = element.offsetWidth; // 강제로 reflow를 시켜 classList remove/add 로직이 무시되는 현상을 막는다.
        element.classList.add('active'); // 애니메이션 재생
      
        cancelAnimation.listener = setTimeout(() => {
          element.classList.remove('active'); // 애니메이션 삭제
        }, time || 350);
      }
      
      // styles.js
      const strokeAnimation = css`
        @keyframes stroke {
          0% { transform: scaleX(0); transform-origin: left 0; }
          50% { transform: scaleX(1); transform-origin: left 0; }
          51% { transform: scaleX(1); transform-origin: right 0; }
          100% { transform: scaleX(0); transform-origin: right 0; }
        }
        
        // .active class일 때 animation을 재생
        &.HorizontalLine2.active rect {
          animation-name: stroke;
          animation-duration: 0.35s;
          animation-iteration-count: 1;
        }
      `;


      컴포넌트가 trigger 되면 startAnimation 함수를 실행하여 class에 'active'를 추가한다.
      setTimeout을 이용하여 애니메이션 실행 후 일정 'time'이 지나면 'active' class를 remove한다.
      해당 키를 연속으로 누를 경우 'cancelAnimation' 객체에 저장해 둔 setTimeout listener를 clearTimeout을 이용하여 clear함으로써 버그를 방지한다. 

      위의 애니메이션 재생 로직을 HTML svg, rect 태그에 적용하면 된다.

        // HorizontalLine2.js
      const rectCount = 5;  // 가로 직사각형이 5개
      const gap = 10;  // 직사각형 사이의 gap
      
      const svgHeight = useMemo(() => theme.windowHeight / 2, [theme.windowHeight]);
      const rectHeight = useMemo(() => (svgHeight - ((rectCount - 1) * gap)) / rectCount, [svgHeight]);  // 직사각형 하나의 높이
      const reverse = Math.random() > 0.5;  // 애니메이션의 방향(왼->오, 오->왼)을 랜덤으로 바꿔 생동감을 줌
      
      const flickerRect = useRef();
      useEffect(() => {
        flickerRect.current = document.querySelector('.HorizontalLine2');
      }, []);
      
      ...
      ...
      
      return (
        <S.StyledHorizontalLine2
          className="HorizontalLine2"
          height={svgHeight * 2}
        >
          <S.Svg
            reverse={reverse}
            style={{ width: svgHeight * 2, height: svgHeight }}
          >
            {[...Array(rectCount).keys()].map((index) => (
              <S.Rect x="0" y={(rectHeight + gap) * index} width="100%" height={rectHeight} key={index} />
            ))}
          </S.Svg>
        </S.StyledHorizontalLine2>
      );
      
      // styles.js
      export const StyledHorizontalLine2 = styled.div`
        ...
        ${strokeAnimation};
      `;
      
      export const Svg = styled.svg`
        transform: rotate(${(props) => (props.reverse ? 180 : 0)}deg);
      `;
      
      export const Rect = styled.rect`
        fill: ...
        transform: scaleX(0);
      `;

      바깥의 StyledHorizontal div tag는 'active' class를 추가/삭제하는 역할을 담당하고, svg의 rect를 통해 애니메이션을 그려낸다.

    5. 아래의 링크를 통해 완성된 게임을 직접 플레이해 볼 수 있다!
      https://snu-festival.web.app/activity/mini/guess-the-song
  • Known issues
    1. 음악과 애니메이션 재생 로직 위주로 코드를 요약했다. 배리어 프리를 위해 가사 자막 기능도 추가 구현했지만 포스트에서는 제외했다.
    2. 재작년 개인 프로젝트의 작업 내용이라 코드가 깔끔하지 못한 부분이 있다. touch 이벤트 위임, 중복 코드 제거, 관심사 분리 등을 통해 개선할 수 있을 것이다.