이번 글에서는 Canvas를 사용하여 캐릭터 이동을 구현한 경험을 공유해보려 합니다.
그저 이동 뿐 아니라, 캐릭터가 마치 걷는 듯한 애니메이션을 표현했습니다. (제일 어려웠어요..)
Canvas?
Canvas API는 JavaScript 및 HTML <canvas> 요소를 통해 그래픽을 그리는 수단을 제공합니다.
애니메이션, 게임 그래픽, 데이터 시각화, 사진 조작 및 실시간 비디오 처리에 사용할 수 있습니다.
→ 캐릭터 이동을 위해 동적 그래픽 조작이 필요하기 때문에 Canvas API를 사용합니다!
useCanvas Hook 구현
먼저 canvas를 조작할 수 있는 환경을 마련합니다.
// useCanvas.ts
import { useRef, useEffect } from "react";
const useCanvas = (setCanvas: (canvas: HTMLCanvasElement) => void) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
canvas && setCanvas(canvas);
}, []);
return canvasRef;
};
export default useCanvas;
useRef Hook을 이용한 canvasRef를 리턴하도록 했습니다.
canvas 요소를 후에 더 생성해야 할 수도 있겠다는 생각에 Custom Hook으로 만들었습니다.
useCanvas Hook을 아래 코드에서처럼 활용하여 생성한 canvas를 조작할 수 있도록 합니다.
// Canvas.tsx
import React from 'react';
import { Wrapper } from './style';
import useCanvas from '@src/hooks/useCanvas';
import mapBackground from '@public/images/map_background.jpeg';
const WIDTH = 1000;
const HEIGHT = 700;
const Canvas = () => {
const canvasRef = useCanvas((canvas) => {
canvas.width = WIDTH;
canvas.height = HEIGHT;
canvas.style.background = `url(${mapBackground})`;
});
return (
<Wrapper>
<canvas ref={canvasRef} />
</Wrapper>
);
};
export default Canvas;
canvas가 잘 생성되었네요 👍
캐릭터 그리기
캐릭터의 이동을 위해 필요한 정보가 무엇이 있을까요?
- 캐릭터의 위치 (x, y)
- 캐릭터의 이동 방향
현재로서는 이 두가지 정도가 필요하겠네요.
그럼 위치와 방향 State를 생성하면 될까요?
No! State의 변경이 일어나면 컴포넌트의 리렌더링이 발생합니다.
따라서 캐릭터가 이동할 때마다 리렌더링이 수행되겠죠..
그럼 위치, 방향 정보를 어떻게 관리할까요?
클래스를 하나 만듭시다!
// Character.ts
interface Position {
x: number;
y: number;
}
enum Direction {
DOWN = 0,
UP = 1,
LEFT = 2,
RIGHT = 3,
}
class Character {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D | null = null;
private position: Position = { x: 0, y: 0 };
private direction: number = Direction.DOWN;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = this.canvas.getContext('2d');
}
}
export default Character;
캐릭터 이미지 가져오기
우선 방향별 캐릭터 이미지를 가져오는 기능을 구현해봅시다.
저는 https://itch.io/game-assets/free/tag-top-down 에서 무료 캐릭터 이미지를 다운 받았습니다.
위 이미지를 32px로 모두 자르고 방향에 따라 특정 이미지를 canvas에 그릴 것입니다.
모든 이미지를 활용하면 좋겠지만 각 방향에 따른 걷는 이미지 둘, 서는 이미지 하나만을 사용하겠습니다.
매번 이미지를 로드하게 되면 ERR_INSUFFICIENT_RESOURCES 에러가 발생할 수 있습니다.
한번 로드한 이미지를 재사용하기 위해 아래 코드와 같이 이미지 객체를 생성해놓습니다.
// CharacterImages.ts
import manDown from '@public/images/man_down.png';
import manDownWalk from '@public/images/man_down_walk.png';
import manUp from '@public/images/man_up.png';
import manUpWalk from '@public/images/man_up_walk.png';
import manLeft from '@public/images/man_left.png';
import manLeftWalk from '@public/images/man_left_walk.png';
import manRight from '@public/images/man_right.png';
import manRightWalk from '@public/images/man_right_walk.png';
const init: { [key: string]: HTMLImageElement } = {};
const imageSrc = {
manDown,
manDownWalk,
manUp,
manUpWalk,
manLeft,
manLeftWalk,
manRight,
manRightWalk,
};
const CharacterImages = Object.entries(imageSrc).reduce(
(images, [key, src]) => {
const image = new Image();
image.src = src;
images[key] = image;
return images;
},
init
);
export default CharacterImages;
생성한 이미지 객체를 활용하여 방향별 캐릭터 이미지를 가져오는 함수를 구현합니다.
// Character.ts
import CharacterImages from './CharacterImages';
...
enum Direction {
DOWN = 0,
UP = 1,
LEFT = 2,
RIGHT = 3,
}
class Character {
...
private getImageByDirection(direction: number, isWalking: boolean) {
const {
manDown,
manDownWalk,
manUp,
manUpWalk,
manLeft,
manLeftWalk,
manRight,
manRightWalk,
} = CharacterImages;
switch (direction) {
case Direction.UP:
return isWalking ? manUpWalk : manUp;
case Direction.DOWN:
return isWalking ? manDownWalk : manDown;
case Direction.LEFT:
return isWalking ? manLeftWalk : manLeft;
case Direction.RIGHT:
return isWalking ? manRightWalk : manRight;
default:
return null;
}
}
}
캐릭터를 그리기 위한 준비를 마쳤습니다! 이제 캐릭터를 그려봅시다.
requestAnimationFrame
캐릭터를 그리기 위해서 window.requestAnimationFrame 함수를 사용합니다.
requestAnimationFrame은 콜백을 인자로 받고, 화면 주사율에 맞춰 콜백이 호출됩니다.
이러한 requestAnimationFrame을 재귀 호출하여 애니메이션을 구현할 수 있습니다.
만약 현재 모니터의 주사율이 60Hz라면 콜백 함수가 1초에 60번 실행되는 것입니다.
따라서 사용자는 디스플레이에 최적화된 애니메이션을 느낄 수 있게 됩니다.
private runAnimationFrame() {
this.draw();
requestAnimationFrame(this.runAnimationFrame.bind(this));
}
private draw() {
1. 모든 그래픽 요소 지우기
2. 특정 좌표에 캐릭터 이미지 그래픽 생성
}
requestAnimationFrame을 활용한 위 코드를 통해 캐릭터를 그릴 수 있습니다.
매 프레임마다 캐릭터를 지우고 그리는 것을 반복하면서 마치 이동하는 것처럼 표현할 수 있습니다.
위 코드를 추가한 Character 클래스 코드입니다!
// Character.ts
import CharacterImages from './CharacterImages';
interface Position {
x: number;
y: number;
}
enum Direction {
DOWN = 0,
UP = 1,
LEFT = 2,
RIGHT = 3,
}
const SIZE = 64;
class Character {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D | null = null;
private position: Position = { x: 0, y: 0 };
private direction: number = Direction.DOWN;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = this.canvas.getContext('2d');
this.runAnimationFrame();
}
private runAnimationFrame() {
this.draw();
requestAnimationFrame(this.runAnimationFrame.bind(this));
}
private draw() {
const { x, y } = this.position;
const image = this.getImageByDirection(0, false);
if (!this.ctx || !image) {
return;
}
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(image, x, y, SIZE, SIZE);
}
private getImageByDirection(direction: number, isWalking: boolean) {
const {
manDown,
manDownWalk,
manUp,
manUpWalk,
manLeft,
manLeftWalk,
manRight,
manRightWalk,
} = CharacterImages;
switch (direction) {
case Direction.UP:
return isWalking ? manUpWalk : manUp;
case Direction.DOWN:
return isWalking ? manDownWalk : manDown;
case Direction.LEFT:
return isWalking ? manLeftWalk : manLeft;
case Direction.RIGHT:
return isWalking ? manRightWalk : manRight;
default:
return null;
}
}
}
export default Character;
this.getImageByDirection(0, false)를 호출함으로써 아래 방향으로 정지되어 있는 캐릭터 이미지를 그렸습니다.
캐릭터 이동 구현하기
먼저 키보드 이벤트를 바인딩 합시다.
아래 handleArrowKeydown 메서드를 Character 클래스에 추가합니다.
// Character.ts
hadleArrowKeyDown() {
const distance = SIZE;
const ArrowKeys = [
{
code: '38',
string: 'ArrowUp',
movement: { x: 0, y: -distance },
isMoveable: () => this.position.y > 0,
},
{
code: '40',
string: 'ArrowDown',
movement: { x: 0, y: distance },
isMoveable: () => this.position.y < this.canvas.height - SIZE,
},
{
code: '39',
string: 'ArrowRight',
movement: { x: distance, y: 0 },
isMoveable: () => this.position.x < this.canvas.width - SIZE,
},
{
code: '37',
string: 'ArrowLeft',
movement: { x: -distance, y: 0 },
isMoveable: () => this.position.x > 0,
},
];
const handler = (e: KeyboardEvent) => {
for (let i = 0; i < ArrowKeys.length; i++) {
const { code, string, movement, isMoveable } = ArrowKeys[i];
if ([code.toString(), string].includes(e.key) && isMoveable()) {
this.position.x += movement.x;
this.position.y += movement.y;
}
}
};
return (e: KeyboardEvent) => handler(e);
}
키보드 이벤트에 따른 캐릭터 위치를 변경하도록 합니다.
이때, 캐릭터가 canvas를 벗어나지 못하게 isMoveable 이라는 이동 조건문을 사용합니다.
Canvas 컴포넌트에서 이벤트 바인딩 및 바인딩 해제를 수행하도록 합니다.
// Canvas.tsx
...
const Canvas = () => {
let charcter: Character | null = null;
const canvasRef = useCanvas((canvas) => {
canvas.width = WIDTH;
canvas.height = HEIGHT;
canvas.style.background = `url(${mapBackground})`;
charcter = new Character(canvas);
document.addEventListener('keydown', charcter.hadleArrowKeyDown());
});
// 언마운트시 이벤트 바인딩 해제
useEffect(() => {
return () => {
charcter &&
document.removeEventListener('keydown', charcter.hadleArrowKeyDown());
};
}, []);
return (
<Wrapper>
<canvas ref={canvasRef} />
</Wrapper>
);
};
export default Canvas;
잘 동작하네요 😁
그런데 방향키를 꾹 누르면 캐릭터가 빠르게 이동합니다.
일정 속도로 이동하는 것이 좋겠죠?
어떻게 할 수 있을까요?
바로 throttling(쓰로틀링)을 활용하는 것입니다! (throttling의 개념에 대해서는 해당 글에서 다루지 않겠습니다.)
throttle 유틸 함수를 만듭시다.
// throttle.ts
function throttle(func: Function, delay: number = 1000) {
let timer: NodeJS.Timeout | null = null;
return (...args: unknown[]) => {
if (timer) {
return;
}
func(...args);
timer = setTimeout(() => {
timer = null;
}, delay);
};
}
export default throttle;
생성한 throttle 유틸 함수를 아래와 같이 적용합니다.
// Character.ts
import throttle from '@src/utils/throttle';
...
hadleArrowKeyDown() {
const distance = SIZE;
const ArrowKeys = [
{
code: '38',
string: 'ArrowUp',
movement: { x: 0, y: -distance },
isMoveable: () => this.position.y > 0,
},
{
code: '40',
string: 'ArrowDown',
movement: { x: 0, y: distance },
isMoveable: () => this.position.y < this.canvas.height - SIZE,
},
{
code: '39',
string: 'ArrowRight',
movement: { x: distance, y: 0 },
isMoveable: () => this.position.x < this.canvas.width - SIZE,
},
{
code: '37',
string: 'ArrowLeft',
movement: { x: -distance, y: 0 },
isMoveable: () => this.position.x > 0,
},
];
const handler = throttle((e: KeyboardEvent) => {
for (let i = 0; i < ArrowKeys.length; i++) {
const { code, string, movement, isMoveable } = ArrowKeys[i];
if ([code.toString(), string].includes(e.key) && isMoveable()) {
this.position.x += movement.x;
this.position.y += movement.y;
}
}
}, 500);
return (e: KeyboardEvent) => handler(e);
}
...
일정 속도로 이동하네요 👍
캐릭터 걷는 애니메이션 구현하기
이제 마지막으로 캐릭터가 마치 걷는 듯한 애니메이션을 구현해봅시다.
저는 requestAnimationFrame을 활용했습니다.
키보드 이벤트 발생시 특정 프레임 x 동안에 걷는 이미지를 그리고 특정 프레임 x가 지나면 서는 이미지를 그리는 방법을 사용했습니다.
// Character.ts
import CharacterImages from './CharacterImages';
import throttle from '@src/utils/throttle';
interface Position {
x: number;
y: number;
}
enum Direction {
DOWN = 0,
UP = 1,
LEFT = 2,
RIGHT = 3,
}
const SIZE = 64;
class Character {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D | null = null;
private position: Position = { x: 0, y: 0 };
private direction: number = Direction.DOWN;
private frameDelay = 10;
private frameCount = 0;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = this.canvas.getContext('2d');
this.runAnimationFrame();
}
private runAnimationFrame() {
// (2)
this.draw(++this.frameCount < this.frameDelay);
requestAnimationFrame(this.runAnimationFrame.bind(this));
}
private draw(isDelayIn: boolean) {
const { x, y } = this.position;
// (3)
const image = this.getImageByDirection(this.direction, isDelayIn);
if (!this.ctx || !image) {
return;
}
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(image, x, y, SIZE, SIZE);
}
private getImageByDirection(direction: number, isWalking: boolean) {
const {
manDown,
manDownWalk,
manUp,
manUpWalk,
manLeft,
manLeftWalk,
manRight,
manRightWalk,
} = CharacterImages;
switch (direction) {
case Direction.UP:
return isWalking ? manUpWalk : manUp;
case Direction.DOWN:
return isWalking ? manDownWalk : manDown;
case Direction.LEFT:
return isWalking ? manLeftWalk : manLeft;
case Direction.RIGHT:
return isWalking ? manRightWalk : manRight;
default:
return null;
}
}
hadleArrowKeyDown() {
const distance = SIZE;
const ArrowKeys = [
{
code: '38',
string: 'ArrowUp',
direction: Direction.UP,
movement: { x: 0, y: -distance },
isMoveable: () => this.position.y > 0,
},
{
code: '40',
string: 'ArrowDown',
direction: Direction.DOWN,
movement: { x: 0, y: distance },
isMoveable: () => this.position.y < this.canvas.height - SIZE,
},
{
code: '39',
string: 'ArrowRight',
direction: Direction.RIGHT,
movement: { x: distance, y: 0 },
isMoveable: () => this.position.x < this.canvas.width - SIZE,
},
{
code: '37',
string: 'ArrowLeft',
direction: Direction.LEFT,
movement: { x: -distance, y: 0 },
isMoveable: () => this.position.x > 0,
},
];
const handler = throttle((e: KeyboardEvent) => {
for (let i = 0; i < ArrowKeys.length; i++) {
const { code, string, direction, movement, isMoveable } = ArrowKeys[i];
if ([code.toString(), string].includes(e.key) && isMoveable()) {
this.position.x += movement.x;
this.position.y += movement.y;
this.direction = direction;
}
// (1)
this.frameCount = 0;
}
}, 500);
return (e: KeyboardEvent) => handler(e);
}
}
export default Character;
Character 클래스 전체 코드입니다. 주석으로 표기된 번호를 주시해주세요!
(1) : 키보드 이벤트 발생시 frame count를 0으로 초기화합니다.
(2) : frame count가 frame delay(= 10)보다 작은지 여부를 draw 메서드에 전달합니다.
(3) : 인자로 받은 isDelayIn이 곧 캐릭터의 걸음 여부이므로 isDelayIn을 getImageByDirection 메서드에 전달합니다.
완성 🙌
사용자 맞춤 frame delay 설정하기
현재 구현한 캐릭터 이동에는 큰 문제가 있습니다.
사용자마다 모니터 주사율이 다르다는....
따라서 60Hz보다 주사율이 큰 환경에서는 걷는 애니메이션이 안보이게 될 것입니다.
그래서 사용자의 주사율을 파악하여 그에 맞는 frame delay를 설정할 필요가 있습니다.
requestAnimationFrame 동작 사이의 timestamp를 구하면 대략적인 주사율을 알 수 있습니다.
하지만 이 주사율은 정확한 값이 아닙니다.
저는 60Hz의 모니터 사양임에도 아래와 같은 값들이 나왔습니다.. (timestamp를 여러번 구한 값들입니다.)
그래도 60의 가까운 값들이 출력되는 것을 볼 수 있습니다.
따라서 60Hz, 144Hz, 240Hz를 기준으로 잡고 timestamp와 가장 가까운 주사율을 선택하도록 했습니다.
사용자의 주사율을 얻는 유틸 함수입니다.
// getFrameRate.ts
const FRAME_RATES = [60, 144, 240];
export default async function getFrameRate() {
let count = 0;
let lastTime = 0;
const framePrefixSum = new Map();
FRAME_RATES.forEach((fps) => framePrefixSum.set(fps, 0));
while (++count < 60) {
const timeStamp = 1000 / (performance.now() - lastTime);
const [frameRate] = FRAME_RATES.reduce(
(accumulator, current) => {
const diff = Math.abs(current - timeStamp);
if (accumulator[1] > diff) {
return [current, diff];
}
return accumulator;
},
[0, Infinity]
);
framePrefixSum.set(frameRate, framePrefixSum.get(frameRate) + 1);
lastTime = performance.now();
await new Promise((resolve) => {
requestAnimationFrame(resolve);
});
}
return FRAME_RATES.reduce((accumulator, current) => {
if (framePrefixSum.get(accumulator) < framePrefixSum.get(current)) {
return current;
}
return accumulator;
});
}
조금이라도 높은 정확도로 주사율을 알아내기 위해서, timestamp를 60번 구하고 선정된 주사율의 횟수를 통해 사용자 모니터 주사율을 얻도록 했습니다.
이는 비동기로 작동하기 때문에 프로미스를 리턴하도록 합니다.
getFrameRate 유틸 함수를 적용한 Canvas 컴포넌트와 Character 클래스입니다.
- Canvas 컴포넌트
// Canvas.tsx
import React, { useEffect } from 'react';
import { Wrapper } from './style';
import useCanvas from '@src/hooks/useCanvas';
import Character from '@src/graphics/Character';
import getFrameRate from '@src/utils/getFrameRate';
import mapBackground from '@public/images/map_background.jpeg';
const WIDTH = 1000;
const HEIGHT = 700;
const Canvas = () => {
let charcter: Character | null = null;
const canvasRef = useCanvas(async (canvas) => {
canvas.width = WIDTH;
canvas.height = HEIGHT;
canvas.style.background = `url(${mapBackground})`;
const frameRate = await getFrameRate();
charcter = new Character(canvas, frameRate);
document.addEventListener('keydown', charcter.hadleArrowKeyDown());
});
useEffect(() => {
return () => {
charcter &&
document.removeEventListener('keydown', charcter.hadleArrowKeyDown());
};
}, []);
return (
<Wrapper>
<canvas ref={canvasRef} />
</Wrapper>
);
};
export default Canvas;
- Character 클래스
// Character.ts
import CharacterImages from './CharacterImages';
import throttle from '@src/utils/throttle';
interface Position {
x: number;
y: number;
}
enum Direction {
DOWN = 0,
UP = 1,
LEFT = 2,
RIGHT = 3,
}
const SIZE = 64;
class Character {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D | null = null;
private position: Position = { x: 0, y: 0 };
private direction: number = Direction.DOWN;
private frameDelay = 0;
private frameCount = 0;
constructor(canvas: HTMLCanvasElement, frameRate: number) {
this.canvas = canvas;
this.ctx = this.canvas.getContext('2d');
this.frameDelay = frameRate / 3;
this.runAnimationFrame();
}
private runAnimationFrame() {
this.draw(++this.frameCount < this.frameDelay);
requestAnimationFrame(this.runAnimationFrame.bind(this));
}
private draw(isDelayIn: boolean) {
const { x, y } = this.position;
const image = this.getImageByDirection(this.direction, isDelayIn);
if (!this.ctx || !image) {
return;
}
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(image, x, y, SIZE, SIZE);
}
private getImageByDirection(direction: number, isWalking: boolean) {
const {
manDown,
manDownWalk,
manUp,
manUpWalk,
manLeft,
manLeftWalk,
manRight,
manRightWalk,
} = CharacterImages;
switch (direction) {
case Direction.UP:
return isWalking ? manUpWalk : manUp;
case Direction.DOWN:
return isWalking ? manDownWalk : manDown;
case Direction.LEFT:
return isWalking ? manLeftWalk : manLeft;
case Direction.RIGHT:
return isWalking ? manRightWalk : manRight;
default:
return null;
}
}
hadleArrowKeyDown() {
const distance = SIZE;
const ArrowKeys = [
{
code: '38',
string: 'ArrowUp',
direction: Direction.UP,
movement: { x: 0, y: -distance },
isMoveable: () => this.position.y > 0,
},
{
code: '40',
string: 'ArrowDown',
direction: Direction.DOWN,
movement: { x: 0, y: distance },
isMoveable: () => this.position.y < this.canvas.height - SIZE,
},
{
code: '39',
string: 'ArrowRight',
direction: Direction.RIGHT,
movement: { x: distance, y: 0 },
isMoveable: () => this.position.x < this.canvas.width - SIZE,
},
{
code: '37',
string: 'ArrowLeft',
direction: Direction.LEFT,
movement: { x: -distance, y: 0 },
isMoveable: () => this.position.x > 0,
},
];
const handler = throttle((e: KeyboardEvent) => {
for (let i = 0; i < ArrowKeys.length; i++) {
const { code, string, direction, movement, isMoveable } = ArrowKeys[i];
if ([code.toString(), string].includes(e.key) && isMoveable()) {
this.position.x += movement.x;
this.position.y += movement.y;
this.direction = direction;
}
this.frameCount = 0;
}
}, 500);
return (e: KeyboardEvent) => handler(e);
}
}
export default Character;
💡 getFrameRate 함수가 동작하는 동안 React의 Suspense 또는 useEffect Hook 등을 활용하여 로딩 컴포넌트를 렌더링 해주면 더 훌륭한 결과물을 만들 수 있을 것 같습니다.
💡 해당 글에는 방향별 걷는 이미지를 하나만 사용하고 있습니다. 다리가 교차하도록 두개의 걷는 이미지를 활용한다면 더 멋진 캐릭터 이동을 구현할 수 있을 것입니다.
코드 이해에 참고가 될까 하여 디렉토리 구조 이미지도 넣었습니다!
.
.
.
캐릭터 이동을 구현하면서 정말 많은 문제를 만났습니다.
문제를 해결하는 과정들에서 많은 부분을 학습할 수 있었던 것 같아서 좋네요.
특히 사용자 별로 다른 모니터 주사율을 효과적으로 다룬 것이 제일 뿌듯합니다.
공유할 내용이 많다보니 디테일하게 다루지 못한 것 같네요 😅
개선할 여지가 있는 부분이 있다면 댓글 부탁드립니다!