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 를 넘겨주는 부분이 보이는가? 그럼 이제 GameScene
의 update
에서 저걸 한 번 받아보자. GameScene.update
를 아래처럼 고쳐보자...큭큭큭
update(timeDelta, key){ super.update(timeDelta); if( key === 32 ){ // 스페이스바를 눌렀을 때 this.character.setPivot({x:240, y:0}); } }
그러면 이제 아래와 같은 녀석이 나온다! 이제 드디어 스페이스바로 줄을 걸었다가 놨다가 할 수 있게 되었다 >_ <
배경 만들기
아직까지도 썰렁한 흰 화면에 캐릭터만 어슬렁거리니 이거 너무 허전하다. 이제는 화면을 좀 채워줄 때가 됐다!
우리는 앞으로 가야 한다
1편에서 완성된 게임을 보면 알겠지만 사실 게임 내내 캐릭터의 x축은 변하지 않는다. 대신 배경이 움직인다. 이걸 어떻게 구현하면 좋을까? 일단 간략하게 로직을 정리해보자.
- 캐릭터가 앞으로 가면
- 캐릭터 대신 배경이 뒤로 간다.
- 배경은 한 번 뒤로 가면 캐릭터가 뒤로 간다고 해서 다시 앞으로 가지는 않는다.
- 배경은 몇개의 레이어로 이루어져있고, 각 레이어는 스크롤 속도가 다르다.
그러면 이걸 어떻게 처리하면 좋을까?
여러가지 방법이 있는데 그 중에 간단한건 두 가지 정도를 생각해볼 수 있다.
- 캐릭터의 x 좌표와 관계 없이 캐릭터를 일정한 위치에 그리고 배경을 캐릭터의 x 좌표에 맞춰서 그린다
- 화면이 비추는 카메라의 위치를 캐릭터의 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)
- 더 빨리 움직이는 배경(배경에 있는 산들)
여기 사용된 배경 이미지들은 아래와 같다.
그럼 이 배경들을 어떻게 표시해주면 좋을까? 네 개의 이미지가 있긴 하지만 이 네 개의 이미지 전체를 하나의 게임 오브젝트로 표시하도록 하자. 우선 클래스를 만들어보자.
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으로 고정하기 위해서다. 여기까지 해준 결과는 아래와 같다. 무지무지 그럴싸해져서 넘나 뿌듯한것 ㅠㅠ
그럼 그럴싸한 모습을 즐기면서 다음 시간으로 넘어가도록 하자! 분량이 조금 애매하긴 한데, 지형이랑 아이템은 묶어서 한 챕터로 가는게 좋을 것 같으니 여기서 끊는 센스!