본문 바로가기
dev/javascript

[javascript] 자바스크립트 스프라이트 애니메이션

by 최연탄 2022. 11. 29.
728x90
반응형

참고: https://dev.to/martyhimmel/animating-sprite-sheets-with-javascript-ag3

자바스크립트를 사용하여 HTML5 캔버스에 스프라이트 시트로 애니메이션을 만드는 방법을 알아보겠습니다.

기본 설정

먼저 canvas 요소를 만듭니다.

<canvas width="300" height="200"></canvas>

canvas 요소에 가장자리를 표시하여 가용한 영역이 어디까지인지 확인합니다.

canvas {
  border: 1px solid black;
}

이제 스프라이트 시트(https://opengameart.org/content/green-cap-character-16x18)를 로드합니다. 스프라이트 시트(sprite sheet)는 여러개의 이미지를 하나의 파일에 모아놓은 것으로 주로 연속적인 동작의 컷을 나열하여 애니메이션 조작을 용이하게 하는 이미지 파일입니다. 스프라이트 시트 이미지가 로드되는 동안 캔버스 요소와 2D 컨텍스트를 가져옵니다.

const img = new Image();
img.src = 'https://opengameart.org/sites/default/files/Green-Cap-Character-16x18.png';
img.onload = function() {
  init();
};

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');

function init() {
  // 애니메이션 코드
}

init 함수는 이미지가 로드된 후 img.onload()를 통해 호출됩니다. 이렇게 하는 이유는 이미지를 사용하기 전에 이미지가 모두 로드되었는지 확인하기 위해서 입니다. 이후 모든 애니메이션 코드는 init 함수에 들어갑니다. 여러 이미지를 처리하는 경우에는 Promise를 사용하여 이미지가 모두 로드될 때까지 기다린 후 이미지를 사용하여야 합니다.

스프라이트 시트 (sprite sheet)

이제 준비를 마쳤으니 이미지를 살펴보겠습니다.

이미지의 각각의 행은 애니메이션 사이클을 나타냅니다. 첫 번째(맨 위) 행은 아래방향을 바라보고 걷는 캐릭터이고, 두 번째 행은 위를 바라보고 걷는 캐릭터이고, 세 번째 행은 왼쪽으로 네 번째 행은 오른쪽으로 걷는 캐릭터입니다. 기술적으로  왼쪽 열은 스탠딩(애니메이션 없음)이고 가운데 열과 오른쪽 열은 애니메이션 프레임 입니다. 각 행의 세 개의 프레임 정도면 부드러운 보행을 하는 애니메이션으로 만들 수 있을 것 같습니다.

context의 drawImage 메소드

이미지를 애니메이션화하기 전에 drawImage() context 메소드를 살펴봐야합니다. 이 메소드를 그냥 쓰면 스프라이트 시트를 자동으로 캔버스에 맞게 확대할 것 입니다.

MDN docs - drawImage

확인해 보면 알겠지만 이 메소드에서는 꽤 많은 매개변수를 요구합니다. 특히 세 번째 형태가 우리가 사용할 형태입니다. 하지만 걱정하지 않아도 됩니다. 보이는 것만 큼 나쁘지 않습니다. 각 매개변수는 몇 개씩 논리적 그룹으로 엮여있기 때문입니다.

drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)

첫 번째 매개변수 image는 그림을 그릴 원본 이미지입니다. 그 다음 네 개(sx, sy, sWidth, sHeight)는 원본 스프라이트 시트에 대한 값이고 마지막 네 개(dx, dy, dWidth, dHeight)의 매개변수는 캔버스 영역에 대한 값 입니다.

x와 y 값들(sx, sy, dx, dy)은 스프라이트 시트 원본과 캔버스 목적지의 시작 점입니다. 이미지와 캔버스 모두 좌표계를 사용하며 왼쪽 위를 (0, 0)으로 시작으로 오른쪽 아래로 갈 수록 좌표 값이 증가합니다. 즉 (50, 30)은 오른쪽으로 50 픽셀 아래로 30 픽셀 이동한 위치 입니다.

width와 height 값(sWidth, sHeight, dWidth, dHeight)은 x, y 좌표에서 부터 시작한 스프라이트 시트와 캔버스의 가로 세로 크기를 나타냅니다. 먼저 원본 이미지 섹션 먼저 확인해 보겠습니다. 만일 소스 매개변수(sx, sy, sWidth, sHeight)가 (10, 15, 20, 30)이라면 시작 점은 좌표계 상 (10, 15)이고 끝 점은 (30, 45) 입니다. 끝 점의 좌표를 구하는 공식은 (sx + sWidth, sy + sHeight) 입니다.

첫 번째 프레임 그리기

이제 drawImage() 메소드를 확인해 보겠습니다. 아까 로드했던 스프라이트 시트의 캐릭터 크기는 (16 x 18)로 파일 이름에 쓰여있습니다. 여기서 첫 번째 프레임은 (0, 0)에서 시작하고 (16, 18)에서 끝납니다. 이제 이 영역을 캔버스에 그려보겠습니다. 캔버스에서는 (0, 0)에 그릴 것이고 비율을 유지할 것입니다.

function init() {
  ctx.drawImage(img, 0, 0, 16, 18, 0, 0, 16, 18);
}

See the Pen sprite sheet 1 by shinyks (@shinyks) on CodePen.

위의 예제처럼 실행하면 첫 번째 프레임을 그렸습니다. 그런데 너무 작아 보이네요. 잘 보이게 확대해 보겠습니다. 다음의 예제로 코드를 수정합니다.

const scale = 2;
function init() {
  ctx.drawImage(img, 0, 0, 16, 18, 0, 0, 16 * scale, 18 * scale);
}

이제 캔버스에 그려진 캐릭터가 가로/세로로 두 배 커진게 보일 것입니다. dWidth와 dHeight 값을 수정함으로 캔버스에 그려질 이미지의 크기를 줄이거나 키울 수 있습니다. 하지만 이 작업은 주의를 기울여야합니다. 현재 픽셀을 다루고 있기 때문에 배율 값에 따라 이미지가 흐리게 보일 수도 있습니다.

다음 프레임 그리기

두 번째 프레임을 그리기 위해 해야할 일은 sx와 sy 소스 매개변수를 수정하는 것 입니다. 모든 프레임의 가로와 세로 크기는 같기 때문에 이 두 개의 값은 바꿀 필요가 없습니다. 확인을 위해 캔버스의 첫 번째 프레임 옆에 나머지 프레임을 그려보겠습니다.

const scale = 2;
const width = 16;
const height = 18;
const scaledWidth = scale * width;
const scaledHeight = scale * height;

function init() {
  ctx.drawImage(img, 0, 0, width, height, 0, 0, scaledWidth, scaledHeight);
  ctx.drawImage(img, width, 0, width, height, scaledWidth, 0, scaledWidth, scaledHeight);
  ctx.drawImage(img, width * 2, 0, width, height, scaledWidth * 2, 0, scaledWidth, scaledHeight);
}

위의 예제를 실행하면 다음과 같습니다.

See the Pen sprite sheet 2 by shinyks (@shinyks) on CodePen.

스프라이트 시트의 첫 번째 행 이미지를 모두 그렸습니다. 위의 예제에서 ctx.drawImage() 메소드를 보면 sx, sy, dx, dy 만 바뀐 것을 확인할 수 있습니다. 이제 코드를 좀더 단순화하여 픽셀을 직접 만지는게 아닌 프레임 번호를 사용하도록 수정해 보겠습니다. ctx.drawImage를 다음의 코드로 교체합니다.

function drawFrame(frameX, frameY, canvasX, canvasY) {
  ctx.drawImage(img,
                frameX * width, frameY * height, width, height,
                canvasX, canvasY, scaledWidth, scaledHeight);
}

function init() {
  drawFrame(0, 0, 0, 0);
  drawFrame(1, 0, scaledWidth, 0);
  drawFrame(0, 0, scaledWidth * 2, 0);
  drawFrame(2, 0, scaledWidth * 3, 0);
}

drawFrame() 함수를 스프라이트 시트에 관련된 수치를 만지도록 하여 실제 코드에서 사용할 때는 프레임 번호만 사용하도록 하였습니다.

canvas x, y는 여전히 픽셀 값을 받도록 했는데 이는 캐릭터의 위치를 쉽게 조정하기 위함입니다. scaledWidth 배 만큼을 함수에 전달하면 캐릭터의 위치를 한번에 이동할 수 있습니다.

예제에는 drawFrame() 함수 호출이 하나 더 있는데 이는 스프라이트 시트의 첫 번째 행의 세 개 프레임을 그리는게 아니라 그냥 애니메이션 사이클이 어떻게 될지를 보여주기위함 입니다. 애니메이션 사이클이 왼쪽/오른쪽만 반복하는게 아니라 스탠드 왼쪽 스탠드 오른쪽을 반복하게 하여 약간더 자연스럽게 보이도록 했습니다. 하지만 전자든 후자든 상관없습니다. 80년대 대부분의 게임은 두 단계의 애니메이션을 사용했습니다.

See the Pen sprite sheet 3 by shinyks (@shinyks) on CodePen.

캐릭터 애니메이션화 하기

이제 캐릭터를 애니메이션화할 준비를 마쳤습니다. 이제 MDN docs에서 requestAnimationFrame() 함수를 알아봐야합니다.

이 함수는 애니메이션 루프를 만드는데 사용할 것 입니다. setInterval()을 사용할 수도 있지만 requestAnimationFrame()이 60fps(frame per second)를 만들거나 애니메이션을 멈추기에 좀더 최적화되어있습니다.

기본적으로 requestAnimationFrame() 함수는 재귀함수 입니다. 다음과 같이 애니메이션 루프를 만들기 위해 인수로 전달하는 함수안에서 requestAnimationFrame()을 다시 호출합니다.

window.requestAnimationFrame(step);

function step() {
  // 다른 코드
  window.requestAnimationFrame(step);
}

걷기 기능 루프를 만들기 위해서는 단독으로 호출한 다음 내부에서 계속 호출하게 해야합니다.

이를 사용하기 전에 context 메소드로 clearRect()를 확인해야 합니다. 캔버스에 그림을 그릴 때 만약 같은자리에 계속 drawFrame()을 호출한다면 이전에 그려진 그림위에 새로운 그림이 그냥 덮어써져서 이미지가 곂쳐보이게 됩니다. 이를 해결하기 위해 캔버스 전체의 이미지를 지우도록 해야합니다.

그러므로 그림 그리기 루프는 다음과 같이 첫 번째 프레임을 그릴 때 clear, draw 하고 다음 프레임을 그릴 때 clear, draw 해야합니다.

ctx.clearRect(0, 0, canvas.width, canvas.height);
drawFrame(0, 0, 0, 0);
// 모든 프레임에서 반복

이제 정말로 캐릭터를 애니메이트 해보겠습니다. 애니메이션 싸이클 루프를 가지는 배열에 (0, 1, 0, 2)를 넣고 현재 몇 번 째 싸이클을 돌고있는지 확인하는 변수를 만듭니다. 그리고 메인 애니메이션 루프 작업을 할 step() 함수를 만듭니다.

이 step 함수는 캔버스를 지우고 프레임을 그리고 싸이클 루프 위치를 조정하고 다음 루프를 재귀호출하도록 작성합니다.

const cycleLoop = [0, 1, 0, 2];
let currentLoopIndex = 0;

function step() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawFrame(cycleLoop[currentLoopIndex], 0, 0, 0);
  currentLoopIndex++;
  if (currentLoopIndex >= cycleLoop.length) {
    currentLoopIndex = 0;
  }
  window.requestAnimationFrame(step);
}

그리고 애니메이션 시작을 위해 init() 함수를 수정합니다.

function init() {
  window.requestAnimationFrame(step);
}

See the Pen sprite sheet 4 by shinyks (@shinyks) on CodePen.

너무 빠르게 달리네요.

속도 조절하기

아직까지는 캐릭터가 좀 통제가 안 되는 것 같습니다. 브라우저에서 허용하는 경우 캐릭터는 초당 60프레임 또는 가능한 한 그에 가깝게 그려집니다. 여기서 15프레임마다 프레임을 바꾸도록 제한을 하겠습니다. 이를 위해 현재 어떤 프레임을 그리고 있는지 계속 추적해야 합니다. 그러려면 step() 함수는 호출될 때마다 카운터를 올리지만, 15프레임이 지난 후에만 그림을 그려야 합니다. 코드를 15프레임이 지나면 카운터를 재설정하고 프레임을 그리도록 합니다.

const cycleLoop = [0, 1, 0, 2];
let currentLoopIndex = 0;
let frameCount = 0;

function step() {
  frameCount++;
  if (frameCount < 15) {
    window.requestAnimationFrame(step);
    return;
  }
  frameCount = 0;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawFrame(cycleLoop[currentLoopIndex], 0, 0, 0);
  currentLoopIndex++;
  if (currentLoopIndex >= cycleLoop.length) {
    currentLoopIndex = 0;
  }
  window.requestAnimationFrame(step);
}

See the Pen sprite sheet 5 by shinyks (@shinyks) on CodePen.

훨씬 보기 좋습니다.

방향전환

지금까지는 아래로만 걷는 애니메이션을 만들었습니다. 코드를 수정해서 캐릭터가 사방으로 걷는 애니메이션을 만들어 보겠습니다.

여기서 주의할 점은 아래방향 프레임은 코드 상 0 번째 행(스프라이트 시트의 첫 번째 행)입니다. 위쪽은 1 행, 왼쪽은 2 행, 오른쪽은 3 행 입니다. 프레임 사이클은 모든 행에서 0, 1, 0, 2로 동일하고 이미 코드에 사이클 변화를 적용했기 때문에 이제는 drawFrame() 함수의 두 번째 매개변수를 이용해 행 번호만 바꾸면 됩니다.

현재 방향을 가지고있을 변수도 추가해야합니다. 간단하게 스프라이트 시트의 행 순서를 따라 방향을 지정하겠습니다.

하나의 애니메이션 사이클이 끝나면 다음 방향으로 이동하고 계속 반복해서 모든 방향을 그리도록 하겠습니다. step() 함수는 다음과 같이 수정합니다.

const cycleLoop = [0, 1, 0, 2];
let currentLoopIndex = 0;
let frameCount = 0;
let currentDirection = 0;

function step() {
  frameCount++;
  if (frameCount < 15) {
    window.requestAnimationFrame(step);
    return;
  }
  frameCount = 0;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawFrame(cycleLoop[currentLoopIndex], currentDirection, 0, 0);
  currentLoopIndex++;
  if (currentLoopIndex >= cycleLoop.length) {
    currentLoopIndex = 0;
    currentDirection++; // Next row/direction in the sprite sheet
  }
  // Reset to the "down" direction once we've run through them all
  if (currentDirection >= 4) {
    currentDirection = 0;
  }
  window.requestAnimationFrame(step);
}

See the Pen sprite sheet 6 by shinyks (@shinyks) on CodePen.

관련 글

CSS 애니메이션

자바스크립트 requestAnimationFrame 사용 방법

반응형

댓글