HTML5 미니게임 개발 튜토리얼

이전 글 : Swing my baby 를 통해 보는 HTML5 게임 개발(6)

현재 글 : Swing my baby 를 통해 보는 HTML5 게임 개발(7)

아 대체 언제 끝나는가.... 쓰는 나도 슬슬 지겨워지는 시리즈지만 갈 데 까지 가보자... 사실 지난 편을 쓰고 나서 다른 일들 때문에 까먹고 있다가 이번 편이 늦어지고 말았다 ㅠㅠ 늦어진 만큼 내용을 꾹꾹 눌러 담아서 이번 편에는 마무리를 지어보자.

개발 과정

아이템 상점

코인을 모았으면 써야겠지? 이 코인을 어디다 쓰느냐, 바로 아이템 상점이다!

아이템 상점의 기획은 이렇다.

  • 모은 코인으로 아이템을 구매할 수 있다.

  • 아이템은 점프 물약과 마나 물약 두 가지다.

  • 아이템들은 구매할 때마다 가격이 올라간다.

  • 새로운 게임을 시작하면 가격은 리셋된다.

  • 아이템은 게임 도중 s, d 키로 구매할 수 있다.

  • 아이템을 구매할 수 있을 만큼의 코인이 모이면 화면에 구매 가능하다는 게 표시되어야 한다.

여기서 이 아이템 구매 UI 를 표시하는 주체가 누군지가 미묘해진다. 개념상 GameUI가 그려주는 게 맞는 거 같기도 하고, 아이템 상점이 GameObject를 상속한다면 아이템 상점 자체의 render이벤트에서 그려주는 게 맞을 거 같기도 하고 아리까리하다. 뭐 각자의 취향이 있겠지만 기왕 이렇게 된거 소스 길어지는 것도 귀찮으니 아이템 상점은 별도 클래스로 빼지 말고 걍 데이터는 GameScene이 들고 있고 그려주는 건 GameUI가 하도록 해보자. 이런 더러운 구조가 싫은 분들은 그냥 아이템 상점 클래스를 따로 만들어도 무방하다.

우선 GameSceneconstructorinit에 아래와 같이 아이템들의 가격 초기값을 설정하는 코드를 넣고

this.itemPrice = [500, 800]; // 마나포션 500, 점프포션 800

GameUI.render에 아래처럼 각 아이템 아이콘을 그려주자.

// 아이템 상점 표시 
// 마나 포션 
if (this.character.money >= this.parent.itemPrice[0]) {
    ctx.drawImage(this.img, 160, 151, 50, 51, 200, 540 - 55, 50, 51);
} else {
    ctx.drawImage(this.img, 210, 151, 50, 51, 200, 540 - 55, 50, 51);
}

// 점프 포션 
if (this.character.money >= this.parent.itemPrice[1]) {
    ctx.drawImage(this.img, 160, 202, 50, 51, 255, 540 - 55, 50, 51);
} else {
    ctx.drawImage(this.img, 210, 202, 50, 51, 255, 540 - 55, 50, 51);
}

// 아이템 가격 표시 
ctx.font = '9px verdana';
ctx.textAlign = 'left';
ctx.fillStyle = 'white';
ctx.fillText(this.parent.itemPrice[0], 215, 540 - 7);
ctx.fillText(this.parent.itemPrice[1], 270, 540 - 7);

아이템 가격보다 현재 소지금이 많은 경우에는 컬러 이미지, 그 외에는 흑백 이미지를 그려주고 적절한 위치에 가격을 써주는 코드다. 여기까지 하고 게임을 실행해보면 화면 아래쪽에 아이템 상점 버튼이 표시되는 것을 알 수 있다.

근데 표시만 해주면 뭐하나? 아이템을 살 수 있어야지! 아이템을 살 때는 어떤 처리가 필요한지 우선 생각해보자.

  1. 유저가 아이템 구매 버튼을 누른다.

  2. 현재 소지금이 아이템 가격보다 많거나 같은지 확인

  3. 현재 소지금을 아이템 가격만큼 깐다.

  4. 아이템 가격을 올린다.

  5. 캐릭터에 아이템 효과를 적용한다.

그럼 GameScene.update에 이 내용을 적용해보자. 기존에 캐릭터의 점프를 처리하던 if(key === 32) 블록에 elseif로 붙이도록 하자.

else if (key === 83 || key == 68) { // S , D 
    let itemNo = (key === 83) ? 0 : 1; // s를 누르면 0번, 아니면 1번 
    if (this.character.money >= this.itemPrice[itemNo]) { // 돈이 있나? 
        this.character.money -= this.itemPrice[itemNo]; // 소지금을 까고 
        this.itemPrice[itemNo] += 500; // 아이템 가격을 올리고 
        this.character.gotItem((itemNo == 0) ? "mana" : "jump"); // 아이템 효과 적용
    }
}

아이템 상점에서 파는 아이템의 갯수가 늘어나면 조금 더 처리가 늘어나야하겠지만, 현재로선 이걸로 충분하다. 이제 게임을 실행해보면 아이템을 구매하면서 훨씬 수월해진 게임 진행을 만끽할 수 있다! 대충 이정도면 게임 진행 자체에 필요한 요소는 거의 다 만들어진 것 같다.

캐릭터의 충돌 처리

게임을 자꾸 자꾸 하다 보면 캐릭터가 지면과 닿아서 죽는 시점이 조금 이상하다는 것을 느낄 수 있을것이다. 왜냐면 캐릭터가 줄에 매달려 있을 때 충돌처리의 중심과 캐릭터의 중심이 다른 곳에 있기 때문이다. 우리는 캐릭터가 줄에 매달린 동안 손의 위치를 기준으로 캐릭터의 좌표를 계산하고 있는데, 이렇게 해서 줄과 캐릭터의 손 위치를 정확히 맞추기는 쉬워졌지만 대신에 손을 중심으로 한 원을 가지고 캐릭터의 충돌을 체크하기 때문에 실제 캐릭터의 몸통과는 동떨어진 충돌처리가 이루어지기 때문이다. 이걸 보정해주도록 하자.

먼저 캐릭터에 실제 충돌처리를 위한 좌표를 얻어오는 부분을 추가하자.

get collisionCheckPosition() {
    let frame = this.animations[this.currentAnimation].current;
    return {
        x: this.x - (frame.ox - (frame.sw / 2)),
        y: this.y - (frame.oy - (frame.sh / 2))
    };
}

그리고 Terrain에서 캐릭터와 충돌체크하는 부분에서 그냥 character대신 character.collisionCheckPosition을 사용하도록 수정하면 된다. 아래와 같이 바꿔주면 OK

isHit(character) {
    let firstPoint = {
        x: this.points[0].x,
        y: 540
    };
    let lastPoint = firstPoint;
    let hit = false;
    let chPos = character.collisionCheckPosition; // 캐릭터의 실제 좌표 대신 이걸 쓰자 
    this.points.forEach((pt) => {
        if (Math.distanceToLine(chPos, [lastPoint, pt]) < character.radius) { // 여기랑 
            hit = true;
            return false;
        }
        lastPoint = pt;
    });
    if (hit) return true;
    lastPoint = firstPoint;
    let count = 0,
        cur = 1;
    while (lastPoint.x < chPos.x) {
        if (Math.isCross(chPos, [lastPoint, this.points[cur]])) count++; 
				// 여기에
        lastPoint = this.points[cur];
        cur++;
    }
    if (count % 2 == 0) return false;
    return true;
}

이제 캐릭터가 지형에 깔끔하게 닿았을 때만 부딪히는 걸 확인할 수 있다. 좋아좋아! 이제 게임 외적인 부분을 다듬도록 하자.

게임 상태 처리

우리가 지금까지 만들어온 건 게임 진행중인 상태의 처리들이다. GameScene에서 처리해줘야 할 상태는 크게 두 가지가 있다. 게임 진행중인 상태와 게임 종료 상태다. 쉽게 말해서 캐릭터가 살아서 진행하는 상태와 캐릭터가 죽어있는 상태라고 하면 되겠지? 지난 시간에 캐릭터가 죽었을 경우를 대비해서 update메소드 전체를 if문으로 감싸준 게 기억난다면 대충 어떤 느낌인지 알 수 있을것이다.

게임 오버 상태

게임오버 상태에서 해줘야 하는 것들이 뭐가 있는지 우선 생각해보자.

  • 화면에 게임 오버 상태라는 걸 표시해줘야 한다.

  • 이번 판에 얻은 점수 및 그간의 최고 점수를 표시해줘야 한다.

  • 가능하면 각종 다른 정보들 - 예를 들어 이번 판에 얻은 코인 수나 사용한 코인 액수, 먹은 아이템 수 같은 것을 보여줘도 좋겠지?

  • 랭킹이 있다면 랭킹도 보여줘야 한다.

  • 스페이스바를 누르면 재시작

요 녀석들을 어디서 처리하면 좋을까? 게임하고 별 상관 없으니까 얘들도 UI에서 그려주도록 하자. 일단 게임중에는 화면상의 UI 요소들이 페이드아웃되도록 해볼까?

처리는 되도록 시간을 기준으로 해야 한다. 그러므로 게임오버 화면을 그릴 때도 게임오버가 된 시점부터의 경과 시간을 가지고 이런저런 처리를 해 주는 게 좋겠지? 전체적인 경과 시간을 나타내는 GameScene.elapsed를 게임오버시에 리셋하는 코드를 먼저 넣자. 그리고 this.elapsed += timeDeltaupdate 메소드의 맨 첫 부분으로 옮겨주자.

// GameScene.update 에서 this.state = 1; 아래에 추가 
this.elapsed = 0;

그리고 UI.render에 아래 부분을 추가해주자.

//UI.render 
render(ctx) {
        ctx.save();
        ctx.translate(this.parent.cameraX - 200, 0);
        if (this.parent.state == 1) {
            ctx.globalAlpha = Math.max(0, 1 - this.parent.elapsed); // 1초간 페이드 아웃 
        }
        // ...하략...

이제 죽으면 화면상의 UI 요소들이 슬며~~~시 사라지게 된다. 근데 사라지기만 하면 안되겠지? ctx.restore() 바로 위쪽에 이제 게임오버시의 요소들을 그리는 코드를 추가해보도록 하자.

우선 게임오버시에 사용될 스프라이트를 UI.constructor에 추가하자.

//UI.constructor 
this.gameover = new Sprite(this.img, 0, 400, 348, 81, 174, 0);

'야이 못된놈아 다른 것들은 그냥 그려놓고 왜 게임오버는 스프라이트로 만드냐' 라고 항의한다면 사실 할 말은 없다. 그냥 만든거...긴 하지만 부끄러우니까 가운데 정렬 하기 쉽도록 스프라이트로 만든거라고 뻥을 쳐두도록 하자.

여튼 이걸 다른 UI 요소들이 슬며시 사라진것과는 반대로 슬며~시 나타나도록 만들어보자.

//UI.render 맨 아래쪽 ctx.restore(); 바로 위에 추가 
if (this.parent.state === 1) {
    ctx.globalAlpha = Math.min(1, this.parent.elapsed);
    this.gameover.draw(ctx, 540 / 2, 60);
}

이정도면 넘나 스무스하고 좋긴 한데 뭔가 허전하기도 하다. 우선 점수까지 화면에서 굳이 사라질 필요는 없을 것 같다. 점수를 그려주는 부분을 페이드아웃보다 전으로 옮겨서 게임오버 화면에서도 점수는 나오도록 하자. 그리고 또 뭔가 추가해줄 효과는 없을까? 고전 애니메이션들이 그랬던 것처럼 게임오버가 돠면 캐릭터쪽으로 점점 둥글게 좁혀지는 화면을 만들어보자. 이 효과는 UI를 제외한 다른 오브젝트들을 그릴 때만 적용해야 하므로 GameScene.render를 고쳐주도록 하자.

//GameScene.render 전체는 이렇게 된다
render(ctx) {
    ctx.save();
    ctx.translate(-this.cameraX + 200, 0);
    if (this.state === 1) { // 게임오버 상태일 때 
        // 경과 시간에 비례해 작아지는 반경을 구해서
        let radius = (1.0 - Math.min(this.elapsed, 0.5) * 2) * 540;
        ctx.save(); // 캐릭터 위치에서 해당 반경만큼의 원을 그리고
        ctx.beginPath();
        ctx.arc(this.character.x, this.character.y, this.character.radius * 2 + radius, 0, Math.PI * 2);
        ctx.clip(); // 그 안쪽에만 그림이 그려지도록 제한한 뒤에 
        // UI 를 제외한 다른 자식들을 그려준다. 
        this.background.render(ctx);
        this.terrain.render(ctx);
        this.itemManager.render(ctx);
        this.character.render(ctx);
        ctx.restore(); // 제한을 해제하고 
        this.ui.render(ctx); // UI를 그려주면 끗 
    } else { // 게임중엔 걍 원래대로 다 그려버리면 된다.
        super.render(ctx);
    }
    ctx.restore();
}

쨘! 이걸로 이제 드디어 플레이에 손색이 없는 게임이 되었다! 게임오버 화면에 다른 것들을 각자 이것저것 그려보면서 잠시 게임을 즐겨보도록 하자.

이후에 나올 페이스북 랭킹 섹션에서 랭킹 화면을 그려보게 될테니 그 내용은 나중에..

기타 잡일

어차피 다 같이 쓰는 이미진데 그냥 하나를 돌려쓰자.

우리는 현재 클래스 생성자마다 필요한 이미지를 각자 만들어서 쓰고 있다. 다 똑같은 이미지인데 굳이 이렇게 해봤자 메모리랑 처리 시간만 좀먹을 뿐 아무 이득이 없는데 이럴 필요가 있을까? 이러지 말고 같은 이미지는 하나만 선언해서 그걸 갖다쓰도록 하자. 배경에 쓰이는 이미지들은 어차피 거기 한 군데서만 쓰이니 내버려두고, 계속 쓰이는 스프라이트 이미지를 맨 위에 선언해주자.

const SpriteImage = new Image();
SpriteImage.src = '스프라이트 이미지 주소';

그리고 각 클래스 생성자들에서 this.img = new Image(); this.img.src=...; 으로 되어있는 부분들을 this.img = SpriteImage; 로 바꿔주면 깔끔!

캐릭터의 채찍 디테일

여기서 사용된 캐릭터는 던전앤파이터의 "검마"라는 캐릭터인데, 이 캐릭터는 채찍 형태로 늘어나는 "사복검"이라는 무기를 사용한다. 이 게임도 설정상으로 그 사복검을 휘둘러서 매달리는 거기 때문에 검날의 모양이 있어야 한다. 요렇게..


(상기 이미지의 저작권은 물론 네오플에 있음)

사실은 이미 스프라이트 정의에도 저 칼날조각 아이콘이 있으니까 우리는 그걸 줄 따라 그려주도록 하자. 이 때 주의할 점은, 줄이 길어진다고 칼날 갯수가 늘어나거나 하면 어색하다는 거다! 그러니까 우리는 일정 갯수의 칼날을 줄 길이에 따라 동일한 간격으로 그려주면 되겠다. Character.render를 매만져보자.

// Chrarcter.render 의 if( this.pivot !== null ) 블록 안에 넣어주자 
// 칼날 그리기 
let tPt = {
    x: this.x,
    y: this.y
}; //시작점 
let target = {
    x: this.pivot.x,
    y: this.pivot.y
}; //목표점 
let ang = Math.atan2(target.y - tPt.y, target.x - tPt.x); // 각도를 구하자 
let dist = this.pLen / 12; // 칼날 갯수만큼으로 나눠서 각 칼날의 간격을 구한다 
let bladeAngle = -ang - Math.PI / 2;
for (let i = 0; i < 12; i++) { //칼날은 열두개 
    this.spriteSheet.get(14).draw(ctx, tPt.x, tPt.y, {
        rotate: bladeAngle
    }); // 각도에 맞춰서 그리고
    tPt = Math.getPoint(tPt, ang, dist); // 간격만큼 이동 
}

해주는 김에 기존에 퍼런 색이었던 줄 색도 red로 바꿔주도록 하자.

포커스를 잃었을 때는 게임을 일시정지하기

개발하면서 잠깐 딴짓하다가 오면 게임이 아주 난리가 나있는 걸 계속 봐왔을 것이다. 백그라운드에서 화면이 그려지지 않을 때는 requestAnimationFrame이 호출되지 않으면서 deltaTime이 미친듯이 올라가기 때문...이지만 그딴 거 상세히 알아봤자 머리만 아프니까 우리는 유저가 다른 창으로 포커스를 옮겼을 경우 그냥 게임을 일시정지시키도록 하자.

키보드 이벤트 바인딩할 때 해서 알고 있겠지만 Game객체에서 이벤트 바인딩을 처리하면 된다.

먼저 이벤트 핸들러들을 만들어주자... 별건 없지만.

// Game 에 아래 메소드들을 추가 
blurHandler(e){ 
	this.paused = true;
} 

focusHandler(e){ 
	this.now = this.last = performance.now(); // 일시 정지된 동안의 시간 경과를 무시하도록 
	this.paused = false; 
}

위 핸들러들이 조작하는 대상인 this.pause도 생성자에서 선언해주고 핸들러들도 이벤트에 바인딩 해주자.

// Game.constructor 에 추가 
this.paused = false; 
// 키보드 이벤트처럼 this 를 바인드해서 넘기자
window.addEventListener('focus', this.focusHandler.bind(this), false);
window.addEventListener('blur', this.blurHandler.bind(this), false);

그리고 일시정지가 효과를 발휘하도록 update 메소드를 고쳐보자

// Game.update 전체 내용은 이제 아래와 같을 것이다
update() {
    // 일시정지된 동안은 시간이 흐르지 않게 해주자. 사실 안 그래도 상관은 없지만..
    if (!this.paused) {
        this.last = this.now;
        this.now = performance.now();
        this.timeDelta = (this.now - this.last) / 1000;
        this.elapsed += this.timeDelta;
    }
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
    if (this.scenes.length > 0) {
        // 일시정지중이면 씬의 update 호출을 하지 말자.
        if (!this.paused) this.scenes.last().update(this.timeDelta, this.key);
        this.scenes.last().render(this.ctx);
    }
    this.key = null;
    requestAnimationFrame(this.update.bind(this));
}

시간 계산 부분을 감싼 if문은 실제로는 없어도 동작에 별 변화가 없다. 왜냐면 일시정지된 동안 업데이트를 호출하지 않기 때문이다. 다만 혹시 나중에 elapsed를 쓸 일이 있게 된다면 일시정지된 시간까지 합산되기 때문에 그 점을 생각해서 막아뒀을 뿐..

음소거 버튼

우리가 힘들게 넣은 소리들을 또 누군가는 듣기 싫어할 수도 있다. 소리를 껐다 켰다 할 수 있도록 음소거 버튼을 만들어보자.

우선 UI에서 버튼을 표시해주도록 하자.

일단 생성자에서 버튼 스프라이트를 정의하고

//UI.constructor에 추가 
this.sounds = { 
    on: new Sprite(this.img, 412, 310, 49, 49, 0, 0),
    off: new Sprite(this.img, 462, 410, 49, 49, 0, 0) 
}

그림으로 그려주면 된다

//UI.render 에 추가 
// 소리 온/오프 표시 
this.sounds[ ( this.parent.soundManager.enable ? "on":"off" ) ].draw(ctx, 540 - 54, 5);

그럼 이제 이 버튼을 어떻게 동작시킬까? 버튼이니까 당연히 클릭을 할 수 있게 해줘야 할텐데... 우리는 여지껏 마우스와 관련한 내용을 하나도 안 만들었으니 이제부터 꼴랑 이 버튼 하나를 위해 마우스 관련 처리를 해보도록 하자...ㅠㅠ

원래 클릭 가능한 요소가 많이 들어가는 게임이라면 각 오브젝트마다 범위를 가지고 마우스 클릭시에 해당 좌표의 오브젝트에게 클릭되었다는 신호를 보내고.....등의 표준적인 마우스 이벤트 핸들링 시스템을 만들어야 하겠지만 우리 게임은 그딴거 없다. 키보드 신호와 비슷하게 이벤트를 받아서 클릭 위치를 update에서 처리해주도록 하자.

지난번에 만든 키보드 핸들러와 비슷하니 구구절절 설명은 하지 않도록 하자....키보드 핸들러때도 왠지 별 설명은 안했던 것 같지만 그게 뭐 중요한가 헤헿 기분탓이겠지..

// Game.constructor 에 추가 
this.mouse = null; // 마우스 클릭 정보를 담을 변수 
/// 클릭 이벤트 핸들러를 등록하자 
/// 클릭 외에 다른 마우스 이벤트가 필요해질수도 있으니 아래처럼 클릭이라고 명시해줬지만 
/// 아마 이 게임에서는 별로 쓸 일은 없을 것 같다.
this.canvas.addEventListener('click', this.mouseHandler.bind(this, 'click'), false); // 위에서 있지도 않은 메소드를 핸들러로 등록했으니 빨리 메소드도 만들어주자 

mouseHander(type, e) { // type 에는 아까 바인드한 'click'이 넘어오겠지? 
    // css 등으로 캔바스의 논리적 크기와 실제 크기가 다를 수 있으니까 보정을 해서 넘기도록 하자
    let canvasStyle = window.getComputedStyle(this.canvas);
    let xratio = parseInt(this.canvas.width, 0) / parseInt(canvasStyle.width, 0);
    let yratio = parseInt(this.canvas.height, 0) / parseInt(canvasStyle.height, 0);
    let mouseEv = {
        type: type,
        x: e.offsetX * xratio,
        y: e.offsetY * yratio
    };
    this.mouse = mouseEv;
}

//Game.update 의 this.key = null 아래에 
this.mouse = null; //을 넣고 
// this.scenes.last().update(this.timeDelta, this.key) 를 아래처럼 바꾸자 
this.scenes.last().update(this.timeDelta, this.key, this.mouse);

이제 마우스로 게임 화면을 클릭하면 좌표 정보가 GameScene.update로 넘어가겠지? 센스있게 받아주도록 하자.

먼저 GameScene.update의 매개변수 정보를 바꾸자.

update(timeDelta, key, mouse){ // mouse 가 추가되었다

그리고 여기...서 자식들을 업데이트할 때 마우스를 넘겨주는 게 예쁜 그림이 되겠지만 그러면 넘나 귀찮아지니까 우리는 더럽게 여기서 바로 처리해주도록 하자. 우리의 음소거 버튼은 위치가 늘 고정되어있기 때문에 클릭 여부를 알기가 아주 간단한 편이다. 그리는 좌표 알고, 스프라이트 가로세로 크기 아니까 그냥 해당 위치를 클릭했을 때 간단하게 SoundManager.toggle()을 호출해주도록 하자.

//GameScene.update 의 맨 아래에 추가해주자
if( mouse && mouse.x < 540-54 !== mouse.x < 540 - 5 && mouse.y < 5 !== mouse.y < 54){
    this.soundManager.toggle(); 
}

이제 버튼을 클릭하면 깔끔하게 음소거 되는 걸 확인할 수 있다! 헤헿

게임 스타트 씬

멋진 게임을 만들었으니까, 멋진 타이틀 화면도 만들어봐야겠지? 페이지에 들어가자마자 냅다 게임이 시작되고 캐릭터가 죽어버리면 안되니까.. GameScene처럼 GameStartScene을 하나 만들어보자.

타이틀화면 위에 스페이스를 누르라는 메시지가 깜빡이고 페이스북 로그인 버튼이 있는 형태다. 페이스북 로그인은 나중에 설명하도록 하고 우선 화면을 만들고 스페이스를 누르면 게임 씬으로 넘어가도록 만들어보자.

초반에 만들었던 Game 클래스에는 씬들을 관리하는 메소드가 있었다. 하지만 씬에서 직접 Game클래스의 메소드들을 호출할 방법은 없는 상태다. 씬에다가 부모의 메소드를 넘겨주거나 부모 객체 자체를 전달해주는 방법도 있지만 그런건 이미 많이 했으니까 이번엔 이벤트 패턴으로 만들어보자. 우선 Scene 객체에 아래 내용을 추가한다.

// Scene.constructor 에 추가 
this.events = {};

생성자에서 events를 만들었으니 아래와 같은 두 개의 메소드를 추가하자.

// 이벤트 핸들러 등록 메소드 
on(type, handler) {
    // 핸들러 처음 정의될 때는 우선 핸들러 큐를 만들고 
    if (!this.events[type]) {
        this.events[type] = [];
    }
    // 이미 같은 핸들러가 이벤트에 등록되어 있으면 아무일도 하지 않고.. 
    if (this.events[type].some(fn => fb == handler)) return;
    // 그렇지 않은 경우에는 핸들러를 등록하자. 
    this.events[type].push(handler);
}

// 이벤트 발생시키는 메소드 
fire(type, arg) { // 핸들러가 존재하면 
    if (this.events[type]) {
        // 호출해주면 끗 
        this.events[type].forEach(fn => fn(type, arg));
    }
}

그럼 이제 Game 클래스에 이벤트 핸들러를 만들자.

// Game 클래스에 추가 
sceneEventHandler(type, arg) {
    switch (type) {
        case 'push':
            this.push(arg);
            break;
        case 'pop':
            this.pop();
            break;
    }
}

보다시피 별로 하는 일은 없다... 그럼 이 이벤트 핸들러를 씬마다 설정해주면 되겠지? 모든 씬이 거쳐가는 push메소드를 고치면 간단하다.

// Game.push 의 _scene.init(); 아래에 추가 
_scene.on('push', this.sceneEventHandler.bind(this));

그럼 준비는 갖춰졌으니 GameStartScene을 만들까? 하는 일이 별로 없으니 코드를 보자.

class GameStartScene extends Scene {
    constructor() {
        super();
        this.bgImg = new Image();
        this.bgImg.src = 'title.png';
        this.img = Images.sprites;
        // 스타트 버튼을 누르라는 안내문구 
        this.pressStart = new Sprite(this.img, 140, 375, 272, 25, 136, 0);
    }
    update(timeDelta, key, mouse) {
        super.update(timeDelta);
        if (key == 32) {
            this.fire('push', new GameScene());
        }
    }
    render(ctx) {
        ctx.drawImage(this.bgImg, 0, 0);
        if (this.elapsed % 1 < 0.5) {
            this.pressStart.draw(ctx, 270, 425);
        }
    }
}

그리고 이제 스크립트 맨 밑에서 게임을 생성하는 부분을 수정하면 된다.

var game = new Game(document.getElementById("canv"));
game.push(new GameStartScene()); // GameScene 대신 GameStartScene 으로..
game.update();

페이스북 랭킹

게임은 혼자 하지만, 그래도 혼자서만 해서는 재미가 없다. 온라인 랭킹 시스템을 붙이면 더 재밌게 즐길 수 있겠지? 하지만 회원 가입 로그인 이런거 만들기도 귀찮고, 만들어봤자 사람들은 꼴랑 이 게임을 하기 위해 회원 가입을 할 만큼 한가하지가 않다. 플랫폼 사업자의 배를 불려주기 위해 페이스북 계정에 기반한 랭킹 시스템을 만들어보자...고는 썼지만 사실 이제 의욕도 없고 대충 어떻게 하는지 훑어만 보고 넘어가도록 하자.

우선 페이스북 앱 관리에서 새 앱을 만들고 게임을 선택하고... 하는 과정은 설명하기 귀찮으므로 각자 알아서 잘 해보도록 하자. 앱을 만들 때 카테고리를 게임으로 골라야 한다는 점을 잊지 말자.

어찌됐건 앱을 다 만들면 이제 게임과 연동을 시켜줘야겠지? 점수를 관장하는 ScoreManager 클래스에 페이스북 연동을 붙여보자.

ScoreManager 코드가 전체적으로 변경되니까 그냥 대충 훑어보자.

class ScoreManager {
    constructor() {
        this.highscore = 0;
        this.gotCoin = 0;
        this.usedCoin = 0;
        this._score = 0;
        this.state = 0; // 페이스북 로그인 상태 
        this.name = ""; // 페이스북 이름
        this.authResp = null; // 페이스북 인증 정보 
        this.inited = false; // 초기화 완료 여부 
        this.scoreBoard = null; // 랭킹 정보 
        this.myRank = -1; // 내 랭크 
        this.appID = '{내 페이스북 앱 ID}'; // 페이스북 앱 관리 화면에 있는 앱 아이디를 넣자 
    }
    // 페이스북에서 점수를 가져오는 메소드
    getScore() {
        FB.api('/me/scores', 'GET', (resp) => {
            if (resp.data.length > 0) {
                this.highscore = Math.max(this.highscore, resp.data[0].score);
                this.name = resp.data[0].user.name;
            }
        });
    }
    // 페이스북에서 친구들의 점수와 랭킹을 가져오는 메소드 
    getScoreBoard() {
        if (this.state < 2) return;
        FB.api('/' + this.appID + '/scores', 'GET', {}, (resp) => {
            this.scoreBoard = resp.data.slice();
            this.scoreBoard.forEach((u, i) => {
                if (u.user.id == this.authResp.userID) this.myRank = (i + 1);
                u.user.picture = new Image();
                u.user.picture.src = 'https://graph.facebook.com/' + u.user.id + '/picture';
            });
        });
    }
    // 페이스북 로그인 콜백 
    loginCallback(resp) {
        switch (resp.status) {
            case 'connected': // 로그인 및 연동 성공 
                this.state = 2;
                this.authResp = resp.authResponse;
                break;
            case 'not_authorized': // 페이스북 로그인은 되었지만 게임 연동은 안 됨
                this.state = 1;
                break;
            case 'unknown': //몰라잉
                this.state = 0;
                break;
        }
        if (this.state === 2) {
            this.getScore();
            this.getScoreBoard();
        }
    }
    reset() {
        this._score = 0;
        this.gotCoin = 0;
        this.usedCoin = 0;
    }
    save() { 
      	// 페북 로그인이 되어있지 않으면 저장 안함 헤헿 
        if (this.state != 2 || !this.authResp) return;
        FB.api('/' + this.authResp.userID + '/scores', 'POST', {
            'score': this.highscore
        }, (scoreResp) => {
            this.getScore();
            this.getScoreBoard();
        });
    }
    init() {
        this.score = 0;
        if (this.state == 0) {
            let cb = this.loginCallback.bind(this);
            window.FB.getLoginStatus(cb);
            this.inited = true;
        }
    }
    set score(v) {
        this._score = v;
        if (this._score > this.highscore) this.highscore = this._score;
    }
    get score() {
        return this._score;
    }
    login() { // 페이스북 로그인 
        let cb = this.loginCallback.bind(this);
        FB.login(cb, {
            scope: "publish_actions,user_friends"
        });
    }
    logout() { // 페이스북 로그아웃
        let cb = this.loginCallback.bind(this);
        FB.logout(cb);
    }
}

이렇게 스코어 매니저를 바꿨으면 이제 Game클래스를 변경해서 스코어 매니저를 게임 클래스가 들고 있도록 해보자. 지금은 GameScene에서 스코어 매니저를 생성하고 있지만 그러면 어러 씬들 사이에서 공유가 안되니까..(씬이래봤자 두 개 밖에 없긴 하지만..)

//Game.constructor
// 스코어 매니저 
this.scoreManager = new ScoreManager();

// Game.push 
// _scene.init(); 위에 추가 
_scene.parent = this; // 부모를 지정해주자 

// Scene.constructor 
this.scoreManager = null;
this.parent = null; 

// Scene.init 
this.scoreManager = this.parent.scoreManager;

그리고 GameScene 에서 ScoreManager를 생성하는 부분을 삭제하면 깔끔. init에서도 맨 윗줄에 super.init(); 를 추가해 주자.

그리고 HTML에도 페이스북 API를 사용하기 위한 부트스트래핑 코드를 게임 스크립트보다 전에 넣어주도록 하자. 아마 아래랑 비슷한 모양새일 것이다.

<script>
window.fbAsyncInit = function() {
    FB.init({
        appId: "내 앱의 ID",
        xfbml: true,
        version: "v2.8"
    });
    FB.AppEvents.logPageView();
};
(function(d, s, id) {
    var js, fjs = d.getElementsByTagName(s)[0];
    if (d.getElementById(id)) {
        return;
    }
    js = d.createElement(s);
    js.id = id;
    js.src = '//connect.facebook.net/en_US/sdk.js';
    fjs.parentNode.insertBefore(js, fjs);
}(document, "script", "facebook-jssdk"));
</script>

그럼 이제 Gameupdate를 수정해서 scoreManager.init를 적당한 시기에 호출하도록 해주자.

//Game.update 에 추가
if( !this.scoreManager.inited && window.FB ){ 
    this.scoreManager.init(); 
}

여기까지 했으면 이제 페이스북 로그인 버튼을 게임 스타트 씬에 추가해보자.

//GameStartScene.constructor 에 추가 
this.fbButton = new Sprite(sprites, 400, 90, 145, 90, 0, 0);
this.fbLogged = new Sprite(sprites, 400, 0, 145, 90, 0, 0); 

//GameStartScene.render 에 추가 
if (this.parent.scoreManager.state < 2) { // 페이스북 연동이 되지 않은 경우 
    this.fbButton.draw(ctx, 360, 125);
} else { // 이미 연동된 경우 
    this.fbLogged.draw(ctx, 360, 125); // 나의 최고 점수를 써주자 
    ctx.save();
    ctx.fillStyle = 'white';
    ctx.textAlign = 'center';
    ctx.font = '15px sans-serif';
    ctx.fillText(this.scoreManager.name ? this.scoreManager.name : 'FACEBOOK 로그인 완료', 432, 155);
    ctx.font = '14px sans-serif';
    ctx.fillText('최고기록 ' + this.scoreManager.highscore + 'm', 432, 175);
    ctx.restore();
}

여기까지 했으면 처음 우리가 목표로 했던 게임 시작 화면이 나오고 있을 것이다 >_< 넘나 좋긴 하지만 여기서 끝이 아니다. 버튼을 클릭하면 페이스북 로그인을 할 수 있도록 해줘야겠지? GameStartScene.update를 고쳐보자.

// GameStartScene.update 에 추가 
if (mouse && this.scoreManager.state < 2 && (mouse.x <= 360 != mouse.x <= 505) && (mouse.y <= 125 != mouse.y <= 215)) {
    this.scoreManager.login();
}

보면 알겠지만 그냥 적당한 위치를 클릭하면 페이스북 로그인 메소드를 호출하게 된다. 로그인 과정은 페이스북이 알아서 해줄거고 우리는 그저 페북 굿굿 하면서 떡이나 먹으면 되는 편리한 부분!

이렇게 로그인과 점수 연동까지 했으면 랭킹을 표시해줘야겠지? 랭킹 표시를 위해 다시 UI 클래스를 만져주도록 하자. 우선 게임 화면에서 내 순위를 표시해볼까?

//UI.render 의 최고기록 표시 밑에 추가 
// 내 순위 표시 
if (this.scoreManager.state == 2) {
    ctx.font = '12px verdana';
    ctx.fillStyle = 'white';
    let str = this.scoreManager.name ? this.scoreManager.name + '님' : 'FACEBOOK 로그인 됨';
    if (this.scoreManager.myRank > 0) str = this.scoreManager.myRank + '위/' + str;
    ctx.fillText(str, 0, 540 - 3);
}

쨘! 이제 페이스북 로그인을 하고 게임을 하면 현재 내 순위가 화면 맨 밑에 예쁘게 나온다! 헤헿 이 여세를 몰아서 게임 오버 화면에도 랭킹을 그려주자!

// UI.render 의 this.gameover.draw 바로 밑에 추가하자
if (this.scoreManager.state == 2 && this.scoreManager.scoreBoard) { // 페북 연동이 된 상태고 랭킹 정보가 있을 때
    let wc = 540 / 2;
    ctx.fillStyle = 'white';
    ctx.save();
    // 나보다 최대 5등 높은 애부터 화면에 표시해준다. 
    // 왜냐면 랭킹에 너무 많은 사람이 있으면 내가 안 보이는 슬픈 일이 생기기 때문이다.
    let start = Math.max(0, this.scoreManager.myRank - 5);
    ctx.translate(0, -(start * 60)); // 각 항목의 높이는 대충 60으로 정했다.
    ctx.strokeStyle = 'white';
    ctx.lineWidth = 1.0;
    ctx.strokeRect(3, 180, 534, 222); // 화면에 사각형을 그려주고 
    this.rankTitle.draw(ctx, wc, 180); // 랭킹을 그려주자 
    this.scoreManager.scoreBoard.slice(start, 8).forEach((v, i) => {
        let j = i % 4;
        let y = j * 45,
            x = i < 4 ? 3 : wc;
        ctx.font = 'bold 32px verdana';
        ctx.fillText((start + i + 1), 30 + x, y + 237);
        ctx.font = 'bold 14px sans-serif';
        ctx.fillText(v.user.name, 110 + x, y + 220);
        ctx.font = '16px verdana';
        ctx.fillText(v.score + 'm', 110 + x, y + 240);
        if (v.user.picture) {
            ctx.drawImage(v.user.picture, 0, 0, 50, 50, 60 + x, y + 205, 40, 40);
        }
        ctx.strokeStyle = 'white';
        ctx.strokeRect(60 + x, y + 205, 40, 40);
    });
    ctx.restore();
    // 로그아웃 버튼도 대충 넣자
    ctx.fillStyle = '#3b5998';
    ctx.fillRect(540 - 85, 540 - 35, 80, 30);
    ctx.fillStyle = 'white';
    ctx.font = '14px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText('로그아웃', 540 - 45, 540 - 15);
} else {
    // 로그인 안 한 경우엔 로그인 버튼을 넣어주면 되겠지?
    ctx.textAlign = 'center';
    ctx.font = '16px sans-serif';
    ctx.fillText('Facebook에 로그인해서 친구들의 점수를 알아보세요!', 540 / 2, 220);
    this.fbButton.draw(ctx, 540 / 2, 250);
    ctx.textAlign = 'left';
}

그냥 노가다 코드라 딱히 설명할 부분은 별로 없다.. 게임 화면이 좁기 때문에 랭킹을 네 명씩 좌우로 나누어 보여주느라 코드가 쓸데없이 조금 긴 측면이 있지만 사실 별 내용이 없으니 안심하고 만들어보자.

여기에서 로그아웃과 로그인 버튼을 그려줬으니 클릭하면 로그아웃 / 로그인 처리를 해줘야겠지? 근데 우리는 지난번에 음소거 버튼 클릭 처리를 UI가 아니라 GameScene에서 대충 때워버렸기 때문에 이제와서 구조를 뜯어 고치기는 귀찮다는 문제가 있다. 게다가 UI클래스에는 딱히 update메소드도 별도로 정의한 게 없다. 그러니까 우리는 게임 장면에서 마우스 클릭을 감지하면 UI 클래스에만 좌표를 넘겨서 처리하는 간단한 방식으로 바꿔보자.

우선 UI 클래스에 click메소드를 추가하자

click(mouse) {
    if (this.parent.state === 1) { // 게임오버 화면에서 클릭한 경우
        // 페이스북 로그인 상태에 따라 갈린다.
        if (this.scoreManager.state === 2) { // 로그인 되어 있으면 로그아웃 버튼만 처리하면 되고 
            if (mouse && (mouse.x <= 540 - 65 !== mouse.x <= 540 - 5) && (mouse.y <= 540 - 35 != mouse.y <= 540 - 5)) {
                this.scoreManager.logout();
            }
        } else { // 그 외엔 로그인 버튼만 처리하면 된다. 
            if (mouse && (mouse.x <= 163 !== mouse.x <= 376) && (mouse.y <= 250 != mouse.y <= 312)) {
                this.scoreManager.login();
            }
        }
    } // 음소거 버튼은 게임오버든 아니든 작동해야 한다
    if (mouse && mouse.x < 540 - 54 !== mouse.x < 540 - 5 && mouse.y < 5 !== mouse.y < 54) {
        this.parent.soundManager.toggle();
    }
}

그리고 나서 GameScene.update에서 음소거 버튼에 대한 처리가 있던 부분을 지우고 방금 만든 메소드를 호출해 주자.

if(mouse) this.ui.click(mouse);

그럼 이제 로그인 로그아웃 버튼도 잘 작동하고 음소거 버튼도 잘 동작하는 게임이 드디어 완성됐다!

아 정말 길고도 힘든 여정이었다... 여기까지 읽은 사람은 아마 없겠지만 있다면 기쁨의 치킨을 시켜먹어도 좋다.

마무리

JS 브라우저 호환성

우리의 게임은 최소 <canvas><audio> 태그를 지원하는 브라우저가 필요하다. 바꿔 말하면 IE9 이상의 모던브라우저에서만 동작이 가능하다는 뜻이다. 하지만 IE9에서 이 게임을 실행하려고 하면 아마 실행이 안 될 것이다. 왜냐면 여지껏 작성한 코드들이 ES6 문법을 되는대로 갖다 쓰고 있기 때문이다. 그럼 이걸 어떻게 하면 될까... 다시 첨부터 다시 작성? 은 말도 안되는 소리고 이럴때 쓰라고 바벨이라는 게 있다. 좀 간지나게 하려면 웹팩 같은 번들러와 엮어서 빌드를 하면 좋겠지만 이 게임은 파일이 꼴랑 하나니까 간단하게 온라인 바벨 번역기를 써보자. 위 스크립트 내용을 바벨 사이트 에 넣으면 오른쪽 창에 ES5 에서 실행 가능하도록 변환된 결과가 나온다. 이 변환된 내용을 스크립트 태그에 넣고 게임을 다시 실행해보자.

그래도 아마 안 될텐데, 이 게임에서 쓰는 몇몇 메소드들이 IE9에는 없기 때문이다. 예를 들어서 requestAnimationFrameperformance.now 같은 것들이 그렇다. 이런 것들은 폴리필을 직접 구현해주거나 이미 구현된 폴리필을 사용하면 해결된다. 예를 들면 아래 코드와 비슷하게 구현해서 스크립트 최 상단에 추가해두자.

window.requestAnimationFrame = (function() {
    return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || // if all else fails, use setTimeout 
        function(callback) {
            return window.setTimeout(callback, 1000 / 60); // shoot for 60 fps
        };
})();
(function() {
    if ('performance' in window == false) {
        window.performance = {};
    }
    Date.now = (Date.now || function() { // thanks IE8
        return new Date().getTime();
    });
    if ('now' in window.performance == false) {
        var nowOffset = Date.now();
        if (performance.timing && performance.timing.navigationStart) {
            nowOffset = performance.timing.navigationStart
        }
        window.performance.now = function now() {
            return Date.now() - nowOffset;
        }
    }
})();

그 외에 게임의 로그인 상태를 저장하는 방법이라던지, 여러가지로 더 다듬을 수 있는 방법들이 있겠지만 나머지는 각자 자신의 게임을 만들어보면서 스스로 즐겨보도록 하자!

다들 7회동안 수고 많았고 나중에 웃으면서 보자! 모두모두 앗녕!!

은 난 다신 이런 긴 글 쓰지 말아야지....