HTML5 미니게임 개발 튜토리얼

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

드디어 4편... 이번에는 드디어 캐릭터를 죽여볼 차례가 왔다! 으흐흐흫

지난 편에 왠지 소스코드로 너무 때우는거 아니냐는 지적이 있어서 이번에는 jsfiddle 인용을 되도록 줄여서 가보도록 하자...

개발 과정

지난 시간까지 우리는 캐릭터가 줄을 걸고 앞으로 나아가는 데 까지를 만들었다. 이제는 우리의 캐릭터가 헤딩할 맨 땅을 만들어보자.

지형 만들기

일단 땅바닥을 그려보자

1편에서 게임 영상을 봤다면 기억하고 있겠지만 우리의 게임은 그냥 검은색의 산맥같은 땅을 가지고 있다. 이건 단순히 그리기 편하기 때문이지만, 배경을 잘 깔아뒀으니 그다지 어색하지는 않을것이다. 헤헿. 다른 플랫폼(예를 들어서 GBA라던지...하...)이었다면 이런 방법을 쓰기 어렵지만 우리는 HTML5 캔버스를 쓰고 있으니 간단히 다각형을 그려서 속을 채워줄 수 있다. 이런 좋은 API 를 그냥 썩힐 필요는 없으니 적극적으로 사용해보자.

일단 지형을 어떤 자료구조로 표현할지를 결정해야 한다.

우리의 지형은 대충 저런 그래프와 같은 모양이기 때문에 각 점의 좌표들만 보관하면 된다. 게다가 x축은 일정한 간격이기 때문에 실제로는 각 점의 y축 좌표만 가지고 있어도 단순히 지형을 그려주는 데는 아무런 문제가 없다. 다만 각 점의 x 좌표도 보관해야 나중에 지형을 가지고 이것 저것 쉽게 해볼 수 있으니까 그 점을 염두에 두고 지형 자료 구조는 {x:x좌표, y:y좌표}형태의 객체를 가진 배열로 결정하자.

이 지형을 그려줄 게임 객체인 지형 클래스를 먼저 만들어보자.

class Terrain extends GameObject { constructor(){ this.points = []; //현재 지형 좌표를 보관할 배열 this.tileWidth = 540 / 50; /* x 축 간격을 화면 크기의 50분의 1로 정한다. 그러면 한 화면에는 좌표가 50개 그려지게 된다. 이 값을 늘릴수록 세밀한 지형 표현이 가능하지만 귀찮다. */ this.fillStyle = "black"; // 지형 색 } init(){ this.points = []; } update(timeDelta){ } render(ctx){ // 아래는 현재 지형 배열을 그려주는 코드.. 내용은 별 거 없다. ctx.save(); ctx.fillStyle = this.fillStyle; ctx.beginPath(); ctx.moveTo(this.points[0].x, this.points[0].y); this.points.forEach((pt)=>{ ctx.lineTo(pt.x, pt.y); }); ctx.lineTo(this.points.last().x, 540); ctx.lineTo(this.points[0].x, 540); ctx.fill(); ctx.restore(); } }

자, 이렇게 하면 땅바닥을 그릴 수 있다... 그치만 그릴 땅바닥이 없잖아? 땅바닥을 어떻게 만들면 좋을까? 이제부터 고민해보자.

랜덤으로 생성되는 지형?

우선 가장 속편한 방법은 역시 랜덤이다. 까이꺼 대에에에충 랜덤으로 그려보면 어떨까?

무작위 지형은 도무지 땅같지 않다

테스트 삼아 랜덤으로 땅을 그려보면 이렇게 된다..... 물론 이렇게 된다고 게임을 못 하는 건 아닌데 그래도 이건 좀....

지형은 어느정도 손으로 미리 정의해야 한다.

결국 땅이 땅같아 보이도록 손으로 매만져 줄 수 밖에 없다.

지형을 몇개의 덩어리로 미리 정의해놓고 덩어리들을 계속 붙여서 지형을 이어가면 그럴싸한 땅을 만들 수 있게 된다. 우선은 아까 만든 Terrain 객체를 GameScene에 추가하고 진행하자. 이제 많이 해봐서 알겠지만 GameScene.constructor메소드를 아래와 같이 고쳐주면 된다.

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

그리고 GameScene.init 메소드에도 아래 줄을 추가하고

this.terrain.init();

GameScene.update에서는 지난번에 배경에 했던것처럼 x 좌표를 넘겨주자.

this.terrain.lastX = this.cameraX - 200;

물론 그냥 둬도 괜찮지만 그러면 나쁜 어린이니까 Terrain.init에도 저 변수를 넣어줘야겠지?

this.lastX = 0;

이렇게 하면 일단 기본적으로 지형이 그려질 준비는 끝났다능!

그럼 우선 지형을 몇 개 미리 준비해보자.

x축 거리가 일정하므로 x축은 신경쓰지 말고 위 그림처럼 y 축의 변화만 신경써서 모양을 배열에 표현해보도록 하자. 낮은 지형과 높은 지형을 골고루 준비하되 각 지형들이 부드럽게 연결될 수 있도록 지형들의 시작과 끝의 y 값은 비슷해야 좋겠지?

그럼 이런식으로 프리셋을 몇 개 준비해보자. 이건 어차피 상수니까 그냥 클래스 밖에다가 선언해버릴거야!

const tileSets = [ [5, 5, 6, 7, 6, 5], [5, 10, 15, 15, 10, 8], [5, 10, 15, 15, 18, 20, 16, 8], [5, 8, 11, 14, 17, 20, 30, 20, 10], [5, 30, 32, 34, 36, 30, 20], [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 45, 40, 30, 20, 10], [5, 20, 40, 60, 70, 80, 80, 70, 60, 50, 40, 30, 20, 10], [5, 15, 20, 25, 30, 35, 50, 60, 70, 80, 90, 100, 100, 100, 90, 70, 50, 30, 10], [20,30,40,50,60,80,100,120,140,160,180,200,200,201,201,202,202,200,198,197 ,198,200,80,60,40,20], [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 70, 90, 110, 130, 150, 170, 190, 210, 200, 190, 180, 170, 160, 150, 140, 130, 120, 110, 100, 90, 80, 70, 60, 50, 40, 30, 20, 10] ];

그럼 이제 이렇게 미리 선언해 둔 타일들을 실제로 추가하는 로직을 만들어봐야겠지? 타일을 추가할 때는 어떤 순서로 진행해야 할지 생각해보자.

  1. 현재 points 배열의 길이가 화면 전체 폭(tileWidth = 540/50 이니까 배열 길이가 50개가 되어야 화면 폭만큼이 되겠지?)보다 작을 경우 타일 추가 루틴을 실행한다.
  2. 배열 길이가 50보다 커질때까지
  3. 타일 셋에서 랜덤하게 골라서 현재 타일 배열 뒤에 붙인다.
    1. 붙일때는 x 좌표를 잘 계산해서 붙이도록 하자.

3-1번을 위해서 Terrain.init 메소드에 변수를 하나 더 추가하자.

this.maxX = 0;

이 변수로 뭘 할거냐면, 매번 points 에 좌표를 하나씩 추가할 때마다 여기다가 tileWidth 를 더해줄 예정이다. 그러면 지금 당장 추가해야 할 좌표의 x 값을 쉽게 구할 수 있겠지? 그럼 함 구현을 해보자..

addTile(){ while(this.points.length <= 55){ let src = tileSets[ Math.floor(Math.random()*tileSets.length)|0 ]; src.forEach((y)=>{ this.maxX += this.tileWidth; this.points.push({x:this.maxX, y:540-y}); }); } }

while문의 조건이 50이 아니라 55 이하냐면, 50개로 딱 떨어지면 화면 바로 밖의 포인트가 삭제되기 때문에 양쪽 가장자리의 지형이 뭉개지게 되기 때문이다.

그럼 이제 이 메소드를 언제 호출해야 할까? 바로 위 while문의 조건처럼 points.length가 55보다 작아졌을때겠지? 그치만 이렇게 추가만 하고 제거를 안 하면 한 번 추가한 후에는 조건을 만족시킬 일이 없게 되니까 화면에서 이미 지나간 지점들을 지워주는 코드까지 함께 작성해야 한다.

그럼 이 내용들을 모두 넣어서 Terrain.update를 아래처럼 바꿔보자.

update(timeDelta){ super.update(timeDelta); this.points = this.points.filter((pt)=>{ return pt.x + this.tileWidth > this.lastX; }); if( this.points.length < 55 ) this.addTile(); }

filter 메소드의 내부의 조건은 화면의 좌측 밖으로 tileWidth이상 지나간 좌표만 제거한다는 내용이다. 그리고 나서 다시 게임을 실행해보면 이제 정상적으로 지형이 흘러가는 것을 볼 수 있다. 우리는 흘러가는 코드를 별도로 넣진 않았는데? 그야 우리는 카메라를 캐릭터 따라 계속 앞으로 이동하고 있으니까 제자리에 그려도 자연히 흘러가게 될 수 밖에.. 초반에 코드 양이 적어지니까 카메라를 옮기는 게 편하다고 했던 부분 기억하겠지? 대충 되는대로 둘러댄 말이었지만 이쯤 되면 그게 다 큰 그림의 치밀한 설계였다고 우겨도 괜찮을 것 같다.

충돌 체크

그럴싸하게 움직이는 지형까지 만들어서 겉보기엔 좋아보이지만 아직도 우리의 캐릭터는 줄을 놓으면 저 깊은 무저갱으로 떨어지기만 할 뿐 죽지를 않는다. 이제 캐릭터를 한 번 죽여보자. 이 게임에서 캐릭터가 죽는 유일한 방법은 바로 땅과 부딪히는 것 뿐이다. 드디어 충돌체크를 만들어야 할 때가 오고 만 것이다!(두둥!)

캐릭터가 지형과 부딪히는 경우는 두 가지다. 캐릭터가 지형의 경계선에 닿았거나, 캐릭터가 지형의 내부에 들어갔거나. 그럼 이 두가지 경우에 대해 각각 알아보자.

캐릭터와 어떤 점 간의 충돌

캐릭터에겐 x,y 좌표가 있다. 캐릭터의 픽셀을 정밀하게 판별해서 충돌체크를 하는 방법도 있지만, 이 게임에 그정도의 정밀한 처리는 사실 필요 없다. 간단히 캐릭터의 중심점에서 일정 거리 이내로 들어오면(바꿔 말해서 캐릭터를 하나의 원 모양으로 가정하고 해당 원 안으로 대상 점이 들어오면) 충돌했다고 판단할 수 있다.

예를 들어 캐릭터에 아래와 같은 변수를 추가하고

this.radius = 20;

아래와 같은 메소드로 간단히 캐릭터와 어떤 원형 물체 간의 충돌을 감지할 수 있다.

isHit(x, y, r){ // x, y 좌표와 반지름을 받거나 // 그냥 x,y,r 혹은 x,y,radius 를 가진 객체를 받아서 충돌 여부를 반환 if( typeof x === 'number'){ return Math.distance(this, {x:x, y:y}) < r+this.radius; }else{ return Math.distance(this, x) < (x.r||x.radius) + this.radius; } }

하지만 지형은 점이 아니다. 어떻게 해야 할까?

어떤 점과 다각형의 거리

지형은 다각형이다. 다각형과 캐릭터의 거리가 radius보다 작으면 다각형과 캐릭터는 충돌했다고 볼 수 있다. 그럼 다각형과 캐릭터의 거리는 어떻게 구할까?

다각형은 선분의 집합이고, 선분은 두 점으로 이루어진다. 어떤 점과 캐릭터의 거리를 구하는 방법은 이미 알고 있으니 이번에는 어떤 선과 캐릭터의 거리를 구해보도록 하자.

어떤 점이 선분의 범위 안에 있을때(선분과의 수선이 존재할 때)에는 수선의 길이가 곧 선과의 거리가 된다. 그 외의 경우에는 양쪽 점과의 거리 중 짧은 쪽이 점과 선분과의 거리다.

그럼 이부분을 구현해보자.

Math.distanceToLine = function(pt, line){ // 점에서 선분까지의 거리를 구하자 // line 은 [{x:x1,y:y1}, {x:x2, y:y2}]다. let lineLength = Math.distance(line[0], line[1]); if( lineLength == 0 ) return Math.distance(pt, line[0]); // 길이가 0인 선분과의 거리는 깔끔 let prj = ((pt.x-line[0].x)*(line[1].x-line[0].x)+(pt.y-line[0].y)*(line[1].y-line[0].y))/lineLength; // 그림의 pt2와 같은 경우면 P1과의 거리를 if( prj < 0 ) return Math.distance(pt, line[0]); // P2와 더 가까울 때(수선이 없을 때)는 P2와의 거리를 if( prj > lineLength ) return Math.distance(pt, line[1]); // 그 외에는 노멀 벡터의 길이를 반환하면 된다 return Math.abs(-(pt.x-line[0].x)*(line[1].y-line[0].y) + (pt.y - line[0].y)*(line[1].x-line[0].x))/lineLength; }

그럼 이제 다각형의 각 선분과 캐릭터의 거리를 구해서 그게 캐릭터의 반지름보다 작으면 충돌이라는 걸 알 수 있겠지? 깔끔!

어떤 점이 다각형 내에 포함되었는지 여부

하지만 캐릭터가 지형 속으로 쑥 들어가버린 경우에는 위 방법으로 충돌 체크가 안 될 수도 있다.

그러므로 우리는 어떤 점이 다각형 내부에 있는지도 판별해 보도록 하자!

어떤 점이 다각형의 안에 있는지 밖에 있는지를 판별하는 방법은 의외로 종류가 많다. 하지만 우리 게임에서는 아주 간단한 방식으로 이 부분을 해결해보기로 하자.

기본적인 아이디어는 이렇다. 현재 캐릭터가 있는 점에서 왼쪽으로 수평선을 긋는다. 그렇게 그은 수평선과 지형의 외곽선이 교차하는 횟수를 세서 교차 횟수가 홀수면 캐릭터가 내부에 있고, 짝수거나 0이면 외부에 있다고 보는 거다.

왼쪽으로 긋는 이유는 그냥 그 쪽이 더 짧기 때문이다. 매 프레임 판별을 해야 하기 때문에 되도록이면 수행량이 적은 방향으로 긋는게 당연히 낫겠지?

선분간의 교차를 구하는 방법도 여러가지가 있지만, 우리는 꼼수를 쓰도록 하자. 캐릭터에서 실제로 왼쪽으로 선을 그을 필요는 사실 없다. 그냥 y 좌표가 겹치는 선분중에서 캐릭터보다 왼 쪽에 있는 선분은 교차점이 있다고 보면 된다.

그래서 구현도 간단해진다.

Math.isCross = function(pt, line){ // line1, line2 는 [{x,y}, {x,y}] 형태의 배열 // 우리의 게임에서는 반드시 [0]보다 [1]이 오른쪽에 있게 된다. 따라서 더 간단! if( pt.y > line[0].y != pt.y > line[1].y ){ // y 좌표가 선에 걸쳐질때에만 let atX = (line[1].x-line[0].x)*(line[1].y-line[0].y)/(line[1].y-line[0].y)+line[0].x; if( pt.x > atX ) return true; } return false; }

그럼 이제 지형과 캐릭터의 충돌을 판별하는 메소드도 만들 수 있겠지? Terrain 에 아래와 같은 메소드를 추가하자.

isHit(character){ // 지형을 이루는 각 선분과의 충돌을 체크하자 let firstPoint = {x:this.points[0].x, y:540}; let lastPoint = firstPoint; let hit = false; this.points.forEach((pt)=>{ if( Math.distanceToLine(character, [lastPoint, pt]) < character.radius ){ hit = true; return false; } lastPoint = pt; }); if( hit ) return true; // 혹시 캐릭터가 지형 내부에 있는지도 체크하자 lastPoint = firstPoint; let count = 0, cur = 1; while(lastPoint.x < character.x){ if( Math.isCross(character, [lastPoint, this.points[cur]]) ) count++; lastPoint = this.points[cur]; cur++; } if( count > 0 ) console.log(count); if(count%2 == 0) return false; return true; }

이 메소드가 잘 작동하는지 확인을 어떻게 해볼까? GameScene.update에 아래와 같은 내용을 추가하자.

if( this.terrain.isHit(this.character) ){ this.terrain.fillStyle = "red"; }else{ this.terrain.fillStyle = "black"; }

이렇게 하면 이제 캐릭터가 지형과 겹쳐질 때마다 지형이 빨갛게 표시된다. 그런데 캐릭터가 화면 아래로 완전히 내려가버리면 오히려 지형은 빨개지지 않고 그대로다. 어째서냐면 거긴 사실 땅이 없기 때문이다. 그치만 이건 의도와는 다르니까 그것도 보정해줘야겠지? 위 코드의 if문 조건을 아래처럼 추가하자

if( this.character.y > 540 || this.terrain.isHit(this.character) ){ this.terrain.fillStyle = "red"; }else{ this.terrain.fillStyle = "black"; }

이번 편에서 아이템 구현까지 나갈 수 있을 줄 알았는데 벌써 분량이 꽉 차고 말았따.. 우선 가볍게 여기까지 만든 걸 보고 다음 편에서는 아이템을 제작해 보도록 하자. 꺅 아이템 짱졓아!

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