HTML5 미니게임 개발 튜토리얼

이전 글 : Swing my baby 를 통해 보는 HTML5 게임 개발(2)
현재 글 : Swing my baby 를 통해 보는 HTML5 게임 개발(3)
다음 글 : Swing my baby 를 통해 보는 HTML5 게임 개발(4)

으아 벌써 3편째.... 솔직히 만들떈 대충 슥슥 만들었는데 설명하려니까 이렇게 길어질줄은 몰랐다... 심지어 설명도 대충 적었는데...

개발 과정

지금까지 열심히 줄 타는 캐릭터를 만들었지만, 그냥 지 혼자 흔들거릴 뿐 도무지 재미를 느낄 수가 없었다면 이제 드디어 우리의 캐릭터를 조종해볼 시간이다.

입력 처리

JS는 기본적으로 이벤트 기반으로 입력을 받는다는 건 알고 있을것이다. 그러므로 입력 이벤트를 받아서 업데이트 루프에서 참조할 수 있도록 전달해주면 되겠지?

입력 이벤트를 게임 루프와 조합하기

우선 키보드 입력을 받아줄 변수를 만들자. 보통은 입력 매니저 객체를 만들어서 전체 키 입력을 스캔하는 편이지만 이 게임은 간단하니까 단순히 방금 입력된 키만 보관하면 된다. 우선 오랜만에 보는 게임 객체의 생성자에 입력된 키 값을 보관하는 변수를 추가하자.

this.key = null;

그리고 게임 객체에 키보드 이벤트 핸들러를 만들자.

keyHandler(e){ if( !e.repeat ) this.key = e.keyCode; if( e.keyCode === 32 ){ e.preventDefault(); return false; } }

그리고 다시 생성자로 돌아가서 저 친구를 document 객체에 이벤트 핸들러로 추가해주자.

// this가 뭘 가리키는건지 애매해지지 않도록 바인드해서 넘겨주는 센스 document.addEventListener("keydown", this.keyHandler.bind(this), false);

그러면 이 값에 각 게임장면이 어떻게 접근할 수 있을까? Game.update메소드를 고쳐서 매 업데이트시마다 키값도 함께 넘기도록 하자. 넘기고 나면 쓸모가 없으니 맨 마지막에는 키값을 리셋해주는 걸 잊지 말자. update메소드의 전체는 현재 아래와 같은 모양이 될 것이다.

// 게임 루프용 메소드 // 이 메소드가 매 프레임(1/60초)마다 실행된다. update(){ this.last = this.now; this.now = performance.now(); // 현재 시간 this.timeDelta = (this.now-this.last)/1000; // 지난 프레임과의 경과시간을 초 단위로 환산 this.elapsed += this.timeDelta; // 게임이 시작된 후 경과된 전체 시간 this.ctx.clearRect(0,0,this.canvas.width, this.canvas.height); // 화면을 매 프레임 지워준다. if( this.scenes.length > 0){ // 처리해야 할 장면이 있을 경우에만 this.scenes.last().update(this.timeDelta, this.key); // 활성화된 장면을 갱신 **key를 넘겨주자 this.scenes.last().render(this.ctx); // 활성화된 장면을 그려줌 } this.key = null; requestAnimationFrame(this.update.bind(this)); // 1/60초 후에 다시 실행 }

현재 scene 객체의 update를 호출할 때 경과시간 뒤에 key 를 넘겨주는 부분이 보이는가? 그럼 이제 GameSceneupdate에서 저걸 한 번 받아보자. GameScene.update 를 아래처럼 고쳐보자...큭큭큭

update(timeDelta, key){ super.update(timeDelta); if( key === 32 ){ // 스페이스바를 눌렀을 때 this.character.setPivot({x:240, y:0}); } }

그러면 이제 아래와 같은 녀석이 나온다! 이제 드디어 스페이스바로 줄을 걸었다가 놨다가 할 수 있게 되었다 >_ <

배경 만들기

아직까지도 썰렁한 흰 화면에 캐릭터만 어슬렁거리니 이거 너무 허전하다. 이제는 화면을 좀 채워줄 때가 됐다!

우리는 앞으로 가야 한다

1편에서 완성된 게임을 보면 알겠지만 사실 게임 내내 캐릭터의 x축은 변하지 않는다. 대신 배경이 움직인다. 이걸 어떻게 구현하면 좋을까? 일단 간략하게 로직을 정리해보자.

  1. 캐릭터가 앞으로 가면
  2. 캐릭터 대신 배경이 뒤로 간다.
  3. 배경은 한 번 뒤로 가면 캐릭터가 뒤로 간다고 해서 다시 앞으로 가지는 않는다.
  4. 배경은 몇개의 레이어로 이루어져있고, 각 레이어는 스크롤 속도가 다르다.

그러면 이걸 어떻게 처리하면 좋을까?

여러가지 방법이 있는데 그 중에 간단한건 두 가지 정도를 생각해볼 수 있다.

  1. 캐릭터의 x 좌표와 관계 없이 캐릭터를 일정한 위치에 그리고 배경을 캐릭터의 x 좌표에 맞춰서 그린다
  2. 화면이 비추는 카메라의 위치를 캐릭터의 x 좌표에 맞추고 그냥 그린다.

두 개 다 장단이 있긴 한데 코드가 적어지는 2번으로 가보자.

카메라 이동하기

우선 게임 장면에 카메라의 x 좌표를 보관할 변수를 만들자. init 에다가 살포시 추가하면 된다.

this.cameraX = 0;

그리고 update 에서 이 녀석을 갱신해주자

this.cameraX = Math.max(this.cameraX, this.character.x);

마지막으로 render에서 카메라를 실제로 이동시켜주면 된다. super.render(ctx); 위에다가 아래 코드를 추가하자.

ctx.translate(-this.camera.x + 100, 0);

뜬금없이 튀어나온 +100은 뭐냐면 캐릭터의 왼쪽 여백이다. 캐릭터가 화면 왼쪽에 계속 딱 붙어 있으면 아무래도 보기에도 안 좋고 이상하니까 좌측에서 좀 떨어진 곳으로 고정하는 거.. 왜 화면 중간으로 안 하냐면, 이 게임은 계속 앞으로 가는 게임이니까 캐릭터 뒤쪽은 사실 중요하지 않고 앞쪽이 더 많이 보여야 하기 때문이다.

여기까지 하고 페이지를 다시 확인해보면 이제 진자에 매달려 앞으로 이동하다 보면 어느 위치 이상은 가지 않는 것을 볼 수 있다. 그런데 다시 점프를 하고 줄을 걸면 줄이 뒤로 걸린다. 우리가 테스트용도로 줄이 걸리는 위치를 고정해버렸기 때문이다. 그럼 이 줄을 현재 위치에 따라 45도 각도로 걸도록 코드를 수정해보자.

GameScene.update에 보면 setPivot을 호출하는 부분이 있다. 여기를 아래처럼 바꿔주자.

if( key === 32 ){ var tx = Math.cos(Math.PI/4) * this.character.y + this.character.x; this.character.setPivot({x:tx, y:0}); }

여기까지 하면 아래처럼 이제 앞으로 점프하면서 줄을 걸 수 있게 된다.

원경? 근경? 패럴렉스 스크롤의 이해

먼 옛날 3D를 실시간으로 구현하기는 커녕 화면에 뿌려줄 수 있는 색상이 스무개도 안 되던 시절부터 우리의 선배님들은 게임 내에서 입체감을 구현하기 위해 갖은 고민을 해왔다. 그 중에서도 상당히 오래전부터 아주 효과적으로 사용된 방식이 바로 패럴렉스 스크롤이다. 용어는 있어보이지만 간단히 말해서 배경을 여러개의 레이어로 나누고 "가까운 레이어는 빨리 움직이고 먼 레이어는 천천히 움직이는 것"이 바로 패럴렉스 스크롤이다.

이 게임에서는 배경을 네 개의 레이어로 처리하고 있다.

  1. 움직이지 않는 배경(하늘)
  2. 가장 천천히 움직이는 배경(별1)
  3. 조금 빨리 움직이는 배경(별2)
  4. 더 빨리 움직이는 배경(배경에 있는 산들)

여기 사용된 배경 이미지들은 아래와 같다.

그럼 이 배경들을 어떻게 표시해주면 좋을까? 네 개의 이미지가 있긴 하지만 이 네 개의 이미지 전체를 하나의 게임 오브젝트로 표시하도록 하자. 우선 클래스를 만들어보자.

class Background{ constructor(){ //이미지들을 로딩하자 let imageUrls = ["background_150105.png", "star1_150105.png", "star2_150105.png", "mountains_150105.png"]; this.images = imageUrls.map((v)=>{ let img = new Image(); img.src = v; return img; }); } init(){ this.x = 0; } render(ctx){ ctx.drawImage(this.images[0], this.x, 0); // 하늘은 움직이지 않으니까 일단 걍 그려주자. } }

render에는 기본적으로 움직이지 않아서 한 번만 그려도 되는 하늘을 그려주는 코드가 들어있다. 가로 좌표를 this.x로 그리는 이유는 카메라가 움직여버렸기 때문에 0,0에다 그리면 갈수록 하늘도 뒤로 가버리기 때문이다. 그러면 당연히 안되겠지?

보면 알겠지만 얘는 update를 별도로 구현하지 않았다. 왜냐면 update에서 해줄 일이 전혀 없기 때문이다.. 대신 게임 장면 객체의 update에서 매 프레임마다 cameraX 를 Background.x에 할당해주도록 하자. 게러쎄러 만드는 걸 좋아하는 사람들이 많지만 js는 어차피 특별히 건드리지 않으면 죄다 퍼블릭이다. 직접 쑤셔넣지 않을 이유가 없지! (착한 개발자는 안 따라해도 좋다)

우선 GameScene.constructor에서 이 배경 객체를 생성하고 children 에 넣어주자. 이 때 주의할 점이 있는데, 요 녀석은 Character 보다 먼저 children에 넣어줘야 한다는 점이다. 배경을 먼저 그리고 캐릭터를 그려야 캐릭터가 배경에 가려지지 않겠지? 그리고 기존에 있던 this.character.init()GameScene.init로 옮기기 위해 일단 지워주자.

constructor(){ super(); this.character = new Character(); this.background = new Background(); this.children.push(this.background); this.children.push(this.character); }

그리고 GameScene.init를 아래와 같이 수정하자. 별 건 없고 그냥 초기화 코드를 이쪽으로 다 옮겨주기 위해서다.

init(){ this.background.init(); this.character.init(); this.cameraX = 100; }

그리고 update에서 cameraX 를 갱신하고 배경 객체에도 넘겨주자.

update(timeDelta, key){ super.update(timeDelta); this.cameraX = Math.max(this.cameraX, this.character.x); // 카메라 좌표 갱신 this.background.x = this.cameraX - 200; // 배경 객체에 넘겨주기 if( key === 32 ){ var tx = Math.cos(Math.PI/4) * this.character.y + this.character.x; this.character.setPivot({x:tx, y:0}); } }

알고 있겠지만 카메라 좌표는 한 번 우측으로 이동하면 다시 좌측으로 돌아가지 않도록 하기 위해 Math.max를 써서 이전 값보다 커졌을 때만 변경하도록 했다.

그럼 이제 어떻게 그려주면 될까?

이미지가 계속 흘러가게 하려면 레이어당 몇 개의 이미지가 필요할까? 정답은 두 개다. 어떻게 각 레이어당 두개로 이미지들이 계속 흘러가게 보이게 하는지는 대충 아래의 이미지를 보면 감이 오겠지?

보다시피 그냥 이미지 두 장을 나란히 놓고 슥슥 땡겨주면 된다. 이렇게 땡겨주는 걸 게임 화면만 보면 아래처럼 자연스럽게 연결되게 된다.

잘 보면 쉬운 규칙을 발견할 수 있다. 각 레이어의 1번 이미지는 0,0 좌표에서 -540(화면크기),0 좌표까지 이동하고 2번 이미지는 540,0 에서 0,0까지 이동한다. 각 레이어의 속도만 다를 뿐이다.

그럼 구현을 해보자! Background.render를 아래처럼 수정하면 된다.

render(ctx){ ctx.save(); ctx.translate(this.x, 0); // 모든 레이어에 더해주기 귀찮으니 일단 베이스 좌표를 옮기자. let star1 = -(this.x/8)%540; // 별 레이어 1의 x 값. 캐릭터의 1/8속도다. let star2 = -(this.x/4)%540; // 마찬가지로 별레이어 2의 x값. let mountainX = -(this.x/2)%540; // 산 레이어의 x 값. // 산 레이어는 화면크기보다 세로가 짧아서 임의로 세로 좌표를 설정해봤다. let mountainY = 540 - this.images[3].height; ctx.drawImage(this.images[0], 0, 0); // 하늘은 움직이지 않으니까 일단 걍 그려주자. // 별1 그리기 ctx.drawImage(this.images[1], star1, 0); ctx.drawImage(this.images[1], star1+540, 0); // 별2 그리기 ctx.drawImage(this.images[2], star2, 0); ctx.drawImage(this.images[2], star2+540, 0); // 산 그리기 ctx.drawImage(this.images[3], mountainX, mountainY); ctx.drawImage(this.images[3], mountainX+540, mountainY); ctx.restore(); }

각 레이어의 x 좌표에 % 540을 해주는 이유는 대충 알겠지만 이동 폭을 0 ~ -540으로 고정하기 위해서다. 여기까지 해준 결과는 아래와 같다. 무지무지 그럴싸해져서 넘나 뿌듯한것 ㅠㅠ

그럼 그럴싸한 모습을 즐기면서 다음 시간으로 넘어가도록 하자! 분량이 조금 애매하긴 한데, 지형이랑 아이템은 묶어서 한 챕터로 가는게 좋을 것 같으니 여기서 끊는 센스!

다음 글 : Swing my baby 를 통해 보는 HTML5 게임 개발(4)