React에서 Canvas를 사용한 캐릭터 이동 구현

krkorklo·2022년 10월 15일
3

구현할 내용

게더타운과 유사한 캐릭터 이동 구현

Canvas API?

  • Canvas API는 HTML의 canvas 태그와 JS를 사용해 2D 그래픽을 구현할 수 있도록 도와준다
  • Canvas 태그는 그래픽을 위한 컨테이너 역할, JS 코드가 실제 그래픽 구현 역할을 한다
  • map과 캐릭터 이미지를 띄우고 x, y 좌표를 사용해서 움직이기 위해 canvas API를 사용해보기로 했다

canvas에 배경과 캐릭터 그리기

canvas 선언

우선 canvas 태그를 사용해서 그림을 그릴 수 있는 환경을 만들어줘야한다

function Character() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  return (
    <div>
      <canvas 
        width={window.innerWidth}
        height={window.innerHeight}
        ref={canvasRef} 
      />
    </div>
  );
}

canvas 태그를 만들어주고 useRef hook을 사용해서 canvas DOM에 접근할 수 있게 만든다.

배경과 캐릭터 그리기

function Character() {
  const [background, setBackground] = useState({ x: -800, y: -1000 });
  const [character, setCharacter] = useState({ x: 200, y: 200 });

  useEffect(() => {
    drawBackground();
    drawCharacter();
  }, [character, background]);
  
  const drawBackground = () => {
    const canvasCur = canvasRef.current as HTMLCanvasElement;
    const context = canvasCur.getContext("2d");

    const bgImage = new Image();
    bgImage.src = "/amusement.png";
    context?.drawImage(bgImage, background.x, background.y, 2560, 2048);
  };

  const drawCharacter = () => {
    const canvasCur = canvasRef.current as HTMLCanvasElement;
    const context = canvasCur.getContext("2d");

    const characterImage = new Image();
    characterImage.src = "/character.png";
    context?.drawImage(characterImage, 200, 200, 32, 50);
  };
  
  return (
    <div>
      <canvas 
        width={window.innerWidth}
        height={window.innerHeight}
        ref={canvasRef} 
      />
    </div>
  );
}

drawBackground를 사용해서 canvas에 background image를 그리고, drawCharacter를 사용해서 canvas에 character image를 그린다.
캐릭터 위치가 바뀔 때마다 background image와 캐릭터가 새로 그려져야하기 때문에 useEffect에 character와 background를 dependancy로 주고 선언한다.

이때 background image의 drawImage 좌표는 background.x, background.y이고, character image의 drawImage 좌표는 디폴트값으로 설정된 이유는 실제로 캐릭터 이동을 구현할때 캐릭터를 이동시키는게 아니라 배경을 이동시킬 것이기 때문이다.

게더타운을 보면 방향키가 입력될때 캐릭터가 움직이는 거지만 실제로는 캐릭터는 센터에 고정되어있고 배경화면이 움직이는 것처럼 보인다. 그래서 실제로 구현할 때는 캐릭터의 현재 이동된 좌표를 저장하되 캐릭터의 위치는 디폴트로 고정시켜놓고 배경화면 좌표를 사용해서 움직이려고 저렇게 설정해두었다.

여기까지 해주면 이렇게 배경화면과 캐릭터 이미지가 그려지는 것을 볼 수 있다

캐릭터 이동하기

function Character() {
  ...
  useEffect(() => {
    drawBackground();
    drawCharacter();
  }, [character, background]);
  ...
  
  const handleMove = (e: KeyboardEvent<HTMLCanvasElement>) => {
    if (e.key === "ArrowUp") {
      setCharacter({ ...character, y: character.y - 40 });
      setBackground({ ...background, y: background.y + 40 });
    } else if (e.key === "ArrowDown") {
      setCharacter({ ...character, y: character.y + 40 });
      setBackground({ ...background, y: background.y - 40 });
    } else if (e.key === "ArrowLeft") {
      setCharacter({ ...character, x: character.x - 40 });
      setBackground({ ...background, x: background.x + 40 });
    } else if (e.key === "ArrowRight") {
      setCharacter({ ...character, x: character.x + 40 });
      setBackground({ ...background, x: background.x - 40 });
    }
  };

  return (
    <div>
      <canvas
        width={window.innerWidth}
        height={window.innerHeight}
        ref={canvasRef}
        onKeyDown={handleMove}
      />
    </div>
  );
}

이렇게 keyDown이 될 때마다 handleMove 함수가 실행되고 character와 background의 좌표가 업데이트된다. 이렇게 좌표가 업데이트되면 background, character dependancy가 있는 effect hook이 실행되고 background와 character가 다시 그려져야하는데,,,,,,

아무리 해도 keyDown 이벤트가 인식되지 않고 스크롤만 자꾸 움직였다.

🥹

그래서 검색해보니 html 태그에 keyDown 이벤트를 주려면 해당 태그를 focus해서 keyDown 이벤트를 받을 수 있도록 설정해야한다고 한다.
사용자와 상호작용이 가능한 태그(input, select...)는 Keyboard focus를 잡을 수 있는데, 상호작용하지 않는 태그(div...)들은 keyboad focus를 잡을 수 없다. 상호작용하지 않는 태그에 focus를 부여하는 속성 tabIndex로 canvas에 tabIndex 속성을 추가해줘야 해당 태그에서 keyDown 이벤트를 인식할 수 있다.

  • tabIndex = 0 : 상호작용하지 않는 태그를 상호작용할 수 있게 설정
  • tabIndex = -1 : 상호작용하는 태그를 상호작용할 수 없게 설정
  • tabIndex = + : 상호작용 태그들의 포커스 순서를 설정
return (
  <div>
    <canvas
      width={window.innerWidth}
      height={window.innerHeight}
      ref={canvasRef}
      onKeyDown={handleMove}
      tabIndex={0}
    />
  </div>
);

아직 부자연스럽지만 캐릭터가 움직이는 걸 확인할 수 있다!

캐릭터 이동 방향 인식

지금은 캐릭터가 움직일 때 무조건 정면 이미지로 움직이는데 상하좌우 움직임에 따라 다른 이미지를 불러와서 자연스럽게 이동 방향을 알 수 있게 만들려고 한다.

우선 캐릭터의 상하좌우 다른 이미지를 추가하고 이미지 파일 명을 character_front.png, character_back.png 등 어떤 방향인지 확인할 수 있는 이름으로 설정한다.

방향에 따라 다른 이미지를 렌더링하기 위해 character state에 direction 상태도 추가해준다.

function Character() {
  const [character, setCharacter] = useState({ x: 200, y: 200, dir: "front" });
  
  ...
  
  const drawCharacter = () => {
    const canvasCur = canvasRef.current as HTMLCanvasElement;
    const context = canvasCur.getContext("2d");

    const characterImage = new Image();
    characterImage.src = `/character_${character.dir}.png`;
    context?.drawImage(characterImage, 200, 200, 32, 50);
  };
  
  const handleMove = (e: KeyboardEvent<HTMLCanvasElement>) => {
    if (e.key === "ArrowUp") {
      setCharacter({ ...character, y: character.y - 40, dir: "back" });
      setBackground({ ...background, y: background.y + 40 });
    } else if (e.key === "ArrowDown") {
      setCharacter({ ...character, y: character.y + 40, dir: "front" });
      setBackground({ ...background, y: background.y - 40 });
    } else if (e.key === "ArrowLeft") {
      setCharacter({ ...character, x: character.x - 40, dir: "left" });
      setBackground({ ...background, x: background.x + 40 });
    } else if (e.key === "ArrowRight") {
      setCharacter({ ...character, x: character.x + 40, dir: "right" });
      setBackground({ ...background, x: background.x - 40 });
    }
  };
  ...

이렇게 direction까지 설정해주면 방향에 따른 다른 캐릭터 이미지를 확인할 수 있다.

캐릭터 이동 애니메이션

여전히 캐릭터가 이동할때 뚝뚝 끊기는 느낌이 들면서 이동이 부자연스럽다.
그래서 한 번에 위치를 이동하는게 아니라 setInterval로 쪼개서 이동을 하도록 만들려고 했다.

이동할때 40씩 움직이도록 설정해두었는데 이걸 쪼개서 8씩 5번 움직여서 자연스럽게 이동하게 만들고 싶었다.

  const handleMove = (e: KeyboardEvent<HTMLCanvasElement>) => {
    if (e.key === "ArrowUp") {
      let cnt = 0;
      const timer = setInterval(() => {
        setCharacter({ ...character, y: character.y - 8, dir: "back" });
        setBackground({ ...background, y: background.y + 8 });

        cnt++;
        if (cnt === 5) clearInterval(timer);
      }, 20);
    } else if (e.key === "ArrowDown") {
      let cnt = 0;
      const timer = setInterval(() => {
        setCharacter({ ...character, y: character.y + 8, dir: "front" });
        setBackground({ ...background, y: background.y - 8 });

        cnt++;
        if (cnt === 5) clearInterval(timer);
      }, 20);
    } else if (e.key === "ArrowLeft") {
      let cnt = 0;
      const timer = setInterval(() => {
        setCharacter({ ...character, x: character.x - 8, dir: "left" });
        setBackground({ ...background, x: background.x + 8 });

        cnt++;
        if (cnt === 5) clearInterval(timer);
      }, 20);
    } else if (e.key === "ArrowRight") {
      let cnt = 0;
      const timer = setInterval(() => {
        setCharacter({ ...character, x: character.x + 8, dir: "right" });
        setBackground({ ...background, x: background.x - 8 });

        cnt++;
        if (cnt === 5) clearInterval(timer);
      }, 20);
    }
  };

그런데,,,

이렇게 하면 8씩 5번 움직이는게 아니라 그냥 8만큼만 움직인 사람 된다.

이유는 setInterval 안에 정의된 hook인 setCharacter, setBackground의 state가 제일 처음 들어왔던 character, background만 기억해두기 때문이다.
예를 들어서 character = { x: 200, y: 200 }의 상태에서 오른쪽 화살표키를 눌렀다고 생각해보자. handleMove 함수에 처음 진입할 때의 character는 { x: 200, y: 200}이다. setInterval에서 setCharacter로 x값을 업데이트하게 되는데 이때 우리가 원하는 setInterval의 작동은

setCharacter({ ...character, x: 200 + 8 })
setCharacter({ ...character, x: 208 + 8 })
setCharacter({ ...character, x: 216 + 8 })
setCharacter({ ...character, x: 224 + 8 })
setCharacter({ ...character, x: 232 + 8 })

이것이다.
그런데 hook이 불필요한 렌더링을 막기 위해 비동기적으로 작동하는 성질 때문에 첫 character의 x값인 200만 기억한 채로

setCharacter({ ...character, x: 200 + 8 })
setCharacter({ ...character, x: 200 + 8 })
setCharacter({ ...character, x: 200 + 8 })
setCharacter({ ...character, x: 200 + 8 })
setCharacter({ ...character, x: 200 + 8 })

가 실행되는 것이다.

React는 setState를 여러번 만나면 호출 순서대로 업데이트하지 않고 인자로 전달된 객체들을 하나로 합치는 작업을 진행한다.(Object composition)

Object.Assign(
  {},
  { x: 200 + 8 }, 
  { x: 200 + 8 }, 
  { x: 200 + 8 }, 
  { x: 200 + 8 }, 
  { x: 200 + 8 }
);

이렇게 객체가 하나로 합쳐지면 마지막 값만 반영되고 덮어씌워진다.
결국 값을 전달하지 않고 함수를 전달해서 이전 상태를 호출하고 업데이트하는 functional update를 사용해서 해결했다. functional update는 객체가 아니라 함수가 전달되기 때문에 object composition을 수행하지 않고 호출된 순서대로 큐에 넣고 실행한다.

functional update도 비동기적 수행이다! 다만 이전 update 값이 다음 함수의 인자로 들어가며 값이 업데이트가 되기 때문에 원하는 동작이 가능해진다.

  const handleMove = (e: KeyboardEvent<HTMLCanvasElement>) => {
    if (e.key === "ArrowUp") {
      let cnt = 0;
      const timer = setInterval(() => {
        setCharacter((prevCharacter) => {
          return { ...prevCharacter, y: prevCharacter.y - 8, dir: "back" };
        });
        setBackground((prevBackground) => {
          return { ...prevBackground, y: prevBackground.y + 8 };
        });

        cnt++;
        if (cnt === 5) clearInterval(timer);
      }, 20);
    } else if (e.key === "ArrowDown") {
      let cnt = 0;
      const timer = setInterval(() => {
        setCharacter((prevCharacter) => {
          return { ...prevCharacter, y: prevCharacter.y + 8, dir: "front" };
        });
        setBackground((prevBackground) => {
          return { ...prevBackground, y: prevBackground.y - 8 };
        });

        cnt++;
        if (cnt === 5) clearInterval(timer);
      }, 20);
    } else if (e.key === "ArrowLeft") {
      let cnt = 0;
      const timer = setInterval(() => {
        setCharacter((prevCharacter) => {
          return { ...prevCharacter, x: prevCharacter.x - 8, dir: "left" };
        });
        setBackground((prevBackground) => {
          return { ...prevBackground, x: prevBackground.x + 8 };
        });

        cnt++;
        if (cnt === 5) clearInterval(timer);
      }, 20);
    } else if (e.key === "ArrowRight") {
      let cnt = 0;
      const timer = setInterval(() => {
        setCharacter((prevCharacter) => {
          return { ...prevCharacter, x: prevCharacter.x + 8, dir: "right" };
        });
        setBackground((prevBackground) => {
          return { ...prevBackground, x: prevBackground.x - 8 };
        });

        cnt++;
        if (cnt === 5) clearInterval(timer);
      }, 20);
    }
  };

functional updatesetState를 동기적으로 실행할 수 있게 해서 character, background를 각 interval마다 이동할 수 있도록 만들었다.

여기까지 하면 나름 자연스러운 애니메이션으로 캐릭터를 이동시킬 수 있다.

function useCharacterMove(canvasRef: RefObject<HTMLCanvasElement>) {
  const [background, setBackground] = useState({
    x: INITIAL_BACKGROUND_X,
    y: INITIAL_BACKGROUND_Y,
  });
  const [character, setCharacter] = useState({
    x: INITIAL_CHARACTER_X,
    y: INITIAL_CHARACTER_Y,
    dir: "ArrowDown",
  });

  const drawBackground = () => {
    const canvasCur = canvasRef.current as HTMLCanvasElement;
    const context = canvasCur.getContext("2d");

    bgImage.src = "/amusement.png";
    context?.drawImage(bgImage, background.x, background.y, 2560, 2048);
  };

  const drawCharacter = () => {
    const canvasCur = canvasRef.current as HTMLCanvasElement;
    const context = canvasCur.getContext("2d");

    characterImage.src = `/character_${character.dir}.png`;
    context?.drawImage(
      characterImage,
      INITIAL_CHARACTER_X,
      INITIAL_CHARACTER_Y,
      32,
      50,
    );
  };

  const handleMove = (e: KeyboardEvent<HTMLCanvasElement>) => {
    if (
      e.key !== "ArrowUp" &&
      e.key !== "ArrowDown" &&
      e.key !== "ArrowLeft" &&
      e.key !== "ArrowRight"
    )
      return;
    
    setCharacter({ ...character, dir: e.key });
    let cnt = 0;
    const timer = setInterval(() => {
      setCharacter((prevCharacter) => {
        return {
          ...prevCharacter,
          x: prevCharacter.x + moveLength[e.key].x / 5,
          y: prevCharacter.y + moveLength[e.key].y / 5,
        };
      });
      setBackground((prevBackground) => {
        return {
          ...prevBackground,
          x: prevBackground.x + -moveLength[e.key].x / 5,
          y: prevBackground.y + -moveLength[e.key].y / 5,
        };
      });

      cnt++;
      if (cnt === 5) clearInterval(timer);
    }, 20);
  };
  return { character, background, drawBackground, drawCharacter, handleMove };
}

export { useCharacterMove };

캐릭터를 움직이기 위해 사용되는 로직들을 대충 정리해보면 이렇게 나올 수 있을 것 같다.
☺️


참고자료
https://developer.mozilla.org/ko/docs/Web/HTML/Global_attributes/tabindex
https://eight-bites.blog/en/2021/05/setinterval-setstate/
https://dodokim.medium.com/setstate-를-함수형으로-사용하기-763402cbc3e5

0개의 댓글