HTML canvas에 카메라 달아보기

키보드로 상호작용하는 간단한 2D 게임을 예시로 캔버스에 카메라를 달아봅시다.

Featured image

들어가며

HTML5 표준에는 canvas라는 친구가 있습니다. 무엇이든 손 가는대로 그릴 수 있는 현실의 캔버스 처럼, 웹 상에서 원하는 도형들을 슥삭 하고 그릴 수 있는 친구지요. 정적 이미지 작성에 유리한 svg와는 다르게, 고성능 애니메이션을 작업하는 등 뭔가 화려한 동적인 무언가를 만들 때, 많이 사용되는 친구입니다. 우리에게 익숙한 2D 웹게임이나, 그에 상응하는 것(게더타운 등)에서도 canvas를 사용합니다.

canvas를 사용할 때, 처음 사용자에게 보이는 것이 전부인 경우도 있지만, 그렇지 않은 경우도 상당히 많습니다. 우리가 횡스크롤이나, 탑뷰 게임을 할 때, 상당히 많은 경우에 우리가 보는 화면 크기보다, 맵의 크기가 더 큰 경우가 있고, 그럴때는 캐릭터가 상호작용함에 따라서, 사용자가 보는 화면 또한 달라지게 됩니다. 이를 ‘카메라를 이동한다’ 라고 표현하죠. 이번 글에서는 조작 가능한 네모를 움직일 때, 우리가 맵을 보는 카메라를 옮기는 간단한 웹앱의 코드를 직접 확인해 보면서, HTML canvas의 카메라를 어떻게 구현하는가에 대해서 학습해 봅시다.

들어가기 전에

우선 여기서 설명할 코드는 무려 9년전의 stackoverflow글에서 온 것입니다. canvas camera라는 검색어로 구글 검색을 해봤을 때, 당당히 1위를 차지하고 있는 글인데… 코드가 ES6 문법이 반영되어 있지 않고, 모든 파일을 하나로 한데 합쳐놔서 모던 자바스크립트에 익숙한 저에게 상당히 읽기가 불편했습니다.

그래서 저는 여기서 작성된 코드들을 webpack을 이용해서 모듈들로 쪼개고, class 문법 등을 사용해서 좀 더 현대적인 냄새가 나는 코드로 약간의 리팩토링을 거쳤고, 결과물은 여기에 있습니다.

이제 이 코드를 한차례 한차례씩 뜯어보면서 설명을 시작해 보겠습니다. 물론 전부 다 해체하는것은 아니고, 흐름 위주로요 :)

브라우저에서 불러오는 코드 : index.js

최상위 컴포넌트의 역할입니다. 이 코드에서 하는 역할은 크게 세가지로 나눌 수 있습니다.

  1. 키보드 상하좌우 입력 시 상태 변수 갱신
  2. 게임에서의 캐릭터 위치, 카메라 위치등을 처리해줄 gameObj 객체를 new Game 으로 생성
  3. window.onload로 로딩이 완료되었을 때, 게임 시작 처리

이것 자체는 그렇게 중요한 코드가 아니지만, 처음 해당 코드를 봤을 때, 이게 어떻게 브라우저에서 실행되는지 이해불명으로 머리가 꽁했던 적이 있어서 맨 처음에 적어봅니다.

매 프레임마다 그려주는 Game.js

index.js 에서 내려오는 controlsObj를 받아서, 캐릭터를 움직이는 로직과, 맵을 렌더링하고… 게임 전반의 것들을 모두 담당한다 하면 됩니다. 코드의 부분을 뜯으면서 각 부분이 어떤 역할을 하는지 알아봅시다.

constructor(FPS, controlsObj)(생성자)

update()

사용자의 입력이나 기타 요인에 따라서, 우리가 표현해야 할 상태(플레이어의 위치등)이 달라질 수 있습니다. 이를 매 프레임마다 갱신해 줄 필요는 너무나도 당연합니다.

draw()

정보가 갱신되었다면 다시 그려줘야겠지요?

여기서 ‘그린다’의 기준이 64,65번째 줄을 보면 알 수 있듯이 this.camera.xViewthis.camera.yView 입니다. 카메라에 관해서는 밑의 Camera를 다루는 영역에서 더욱 자세히 알아봅시다.

gameLoop(), play()

매 프레임마다 업데이트를 하고, 다시 그려줘야 합니다. setInterval을 통해서, 정해진 시간(매 프레임)마다 gameLoop을 실행해주고 있는 함수입니다. 모던한 패턴에서는 requestAnimationFrame을 사용한다고 하네요.

배경을 그려주는 GameMap.js

그저 받아온 변수를 할당만 해주는 생성자를 빼면, 맵을 생성하는 generate와, 생성한 맵을 시점에 알맞게 그려주는 draw로 구성되어 있습니다.

generate()

뭔가 굉장한 내용이 담겨있는것 같지만, 사실은 가로, 세로 44px인 빨간 사각형과 파란 사각형이 격자 형태로 배열되어 있는 이미지를 만드는 코드입니다. 전체 캔버스의 크기 만큼 네모들을 그리고, 이를 this.image에 저장합니다.

draw

시점에 알맞게 맵을 그리는 메서드 입니다. 해당 코드의 주석에서 볼 수 있듯, 간단한 방법과 교훈이 있는 방법이 있습니다.

간단한 방법

그냥 캔버스의 도착 좌표만 바꾸고 맵 전체를 그려버리면 됩니다.

context.drawImage(
  this.image,
  0,
  0,
  this.image.width,
  this.image.height,
  -xView,
  -yView,
  this.image.width,
  this.image.height
);

이렇게 하면, 캔버스가 알아서 시점에 따라서 바꿔주긴 하지만, 이러면 딱히 배움의 의미가 없죠. 우리가 직접 이 연산을 해봅시다.

교훈이 있는 방법 (감동이 있다!)

변수명에서 s로 시작하는 것은 source를 의미하고, d로 시작하는것은 destination을 의미한다는 것을 인지하고 설명을 읽어주시기 바랍니다.(context.drawImage의 파라미터 이름과 비슷하죠)

캔버스에서 이미지가 그려질 원점을 (0,0)으로 잡고 배경 이미지를 렌더링 한다고 생각해 봅시다. (const dx = 0; const dy = 0;) 크롭할 이미지의 원점은 당연히 현재 뷰 포인트의 x,y좌표와 같겠죠?(const sx = xView; const sy = yView;)

이렇게 이미지를 가져왔는데, 만약 크롭한 이미지의 높이나 너비가 캔버스의 크기보다 크거나 같다면 상관 없지만 캔버스의 사이즈보다 작다면, 이미지의 사이즈 만큼만 렌더링을 해야할 것입니다(맵 밖으로 나가는것이 금지된다 생각하면요)

// if cropped image is smaller than canvas we need to change the source dimensions
if (this.image.width - sx < sWidth) {
  sWidth = this.image.width - sx;
}
if (this.image.height - sy < sHeight) {
  sHeight = this.image.height - sy;
}

그리고, 목적지의 높이와 너비도, 이비지 출처의 높이와 너비와 같게 세팅해 준 다음(const dWidth = sWidth;const dHeight = sHeight;) 그려주면 됩니다(context.drawImage)

플레이어 그 자체 Player.js

별거 없긴 하지만, 그래도 짚고 넘어가봅시다. 우리가 보는 캔버스 기준 좌표계가 아닌 맵 기준 좌표계를 받았을 때, 캔버스에 알맞게 렌더링 해주고, 키보드의 상태인 control 변수를 받아서 플레이어의 위치를 업데이트 해주는 로직을 모아둔 간단한 클래스 입니다.

생성자

맵 기준 좌표계를 받고, 캐릭터의 속도, 너비, 높이 등을 정의해 줍니다.

update

21~24 번째 줄은 플레이어의 위치값을 변경하고 끝입니다. (step은 프레임 관련해서 아마 쓰지 싶습니다… Game.jsSTEP 이라는 상수를 지정하는 부분이 있습니다.)

27~38번 줄은 플레이어의 위치가 맵을 벗어나지 않게 해주는 간단한 로직입니다. 밑의 draw까지 확인해보면 알 수 있겠지만, 플레이어의 위치는 사각형의 중점으로 정의되고 있습니다. 사각형의 어느 구석 부분도 맵이 렌더링 되지 않은 부분에 들어가지 않도록 if 문으로 보호해주고 있습니다.

draw

사각형의 충돌 검사 Rectangle.js

제목에도 적어놨듯, 해당 클래스는 충돌 감지를 위해서 작성된 클래스 입니다. 우리의 캔버스 형태는 사각형이고, 맵 또한 사각형입니다. 사각형간의 충돌 검사를 통해서, 카메라가 맵 영역 밖으로 튀어나가지 않게끔 검사를 하는 로직입니다. 코드가 전부 충돌 검사에 관한 부분이라서, 이쪽 부분에 대해서는 mdn 링크를 참고하면 좋겠습니다:)

핵심! Camera.js

Game.js를 설명하면서 진짜 간단하게 한마디 하고 넘어갔긴 했는데, CameraxViewyView를 기준으로 Game.js에서 캔버스를 렌더링 합니다. Camera.js가 하는 일은, 이 렌더링의 기준점이 되는 xViewyView를 현재 follow 하는 오브젝트의 위치를 기준으로 잘 갱신해 주는 일 입니다.

핵심인 만큼, Camera 클래스의 프로퍼티에 대한 자세한 설명으로 이 섹션을 시작해보겠습니다.

코드를 읽다가 이해가 되지 않는다면 이쪽 부분을 다시 참고하면서 코드를 읽어주세요!

생성자

xView,yView,viewportWidth,viewportHeight,worldWidth,worldHeight을 받아서 Camera클래스의 프로퍼티의 값을 적절하게 넣어줍니다. 그리고 위에서 언급했던, 뷰포트를 의미하는 사각형인 viewRect와, 카메라가 탐색할 수 이쓴ㄴ 전체 월드를 의미하는 사각형인 worldRect도 받아온 값을 이용해서 초기화 해 줍시다(38~46번째 라인)

follow()

followgameObject, 그리고 위에서 언급했던 xDeadZoneyDeadZone을 받아서 프로퍼티에 적절하게 반영해 줍니다.

update()

코드에 주석으로 거의 다 달아져 있는데 다시 한번 언급하자면, 아래와 같은 역할들을 합니다.

마무리

끝! HTML canvas로 2D게임을 만들 때, 많이 사용하게 될 카메라 개념을 어떻게 구현할 수 있는가에 대해서 나름의 자세한 설명과 함께 알아봤습니다. 현재는 게임 화면에 배경만 있고, 상호작용 할 수 있는것은 월드의 경계 정도 밖에 없지만, 추후에 기회가 된다면 더욱 다양한 기능들을 추가하여 HTML canvas에 관한 여러 web API들의 이해 도모에 도움이 될 수 있는 글을 써보고자 합니다.

끝까지 읽어주셔서 감사합니다!! 🙇🙇