HTML5 미니게임 개발 튜토리얼

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

벌써 6편이라니... 소스코드보다 이 글이 벌써 몇배나 길어진건지 모르겠다... 그치만 무를 뽑았으면 칼이라도 썰어야지! 칼국수 만드는 심정으로 마지막까지 달려가보자.

개발 과정

자 이제 우리의 캐릭터는 줄을 걸고 점프하고 아이템을 먹을 수 있게 됐다. 이쯤되면 이제 정말 게임이다. 그치만 진정한 잉여력은 디테일에서 나오는 법. 이번에는 꼭 필요한건 아니지만 게임을 맛깔나게 해주는 것들을 만들어보자.

이펙트

아이템을 먹을 때나 점프할 때, 줄을 걸 때에 찰진 손맛을 느낄 수 있도록 이펙트를 넣어보자. 파티클이라고도 부르는 친구들인데 뭐 기본은 아이템과 거의 동일하다. 우선 SpriteDefAnimationDef에 이펙트에 쓰일 것들을 추가하자.

// SpriteDefs 에 추가 "effect": [ { x: 160, y: 0, w: 30, h: 30, origin: { x: 15, y: 15 } }, { x: 190, y: 0, w: 30, h: 30, origin: { x: 15, y: 15 } }, { x: 220, y: 0, w: 30, h: 30, origin: { x: 15, y: 15 } }, { x: 250, y: 0, w: 30, h: 30, origin: { x: 15, y: 15 } }, { x: 160, y: 40, w: 30, h: 30, origin: { x: 0, y: 0 } }, { x: 190, y: 40, w: 30, h: 30, origin: { x: 0, y: 0 } }, { x: 220, y: 40, w: 30, h: 30, origin: { x: 0, y: 0 } }, { x: 250, y: 40, w: 30, h: 30, origin: { x: 0, y: 0 } }, { x: 0, y: 250, w: 30, h: 30, origin: { x: 15, y: 15 } }, { x: 30, y: 250, w: 30, h: 30, origin: { x: 15, y: 15 } }, { x: 60, y: 250, w: 30, h: 30, origin: { x: 15, y: 15 } }, { x: 90, y: 250, w: 30, h: 30, origin: { x: 15, y: 15 } }, { x: 0, y: 280, w: 40, h: 40, origin: { x: 20, y: 20 } }, { x: 40, y: 280, w: 40, h: 40, origin: { x: 20, y: 20 } }, { x: 80, y: 280, w: 40, h: 40, origin: { x: 20, y: 20 } }, { x: 120, y: 280, w: 40, h: 40, origin: { x: 20, y: 20 } } ] // AnimationDefs 에 추가 "effect": { 'jump': [ { frame: 0, duration: 0.05 }, { frame: 1, duration: 0.05 }, { frame: 2, duration: 0.05 }, { frame: 3, duration: 0.05 } ], 'clap': [ { frame: 4, duration: 0.05 }, { frame: 5, duration: 0.05 }, { frame: 6, duration: 0.05 }, { frame: 7, duration: 0.05 } ], 'coin': [ { frame: 8, duration: 0.05 }, { frame: 9, duration: 0.05 }, { frame: 10, duration: 0.05 }, { frame: 11, duration: 0.05 } ], 'mana': [ { frame: 12, duration: 0.05 }, { frame: 13, duration: 0.05 }, { frame: 14, duration: 0.05 }, { frame: 15, duration: 0.05 } ] }

이펙트가 하는 일은 간단하다. 생성되어서, 특정한 자리에서 표시되다가, 애니메이션이 완료되면 사라진다. 그야말로 깔끔! 그럼 일단 이펙트 클래스를 만들어보자.

class Effect extends GameObject { constructor(anim, x, y) { super(); this.animation = anim; this.x = x; this.y = y; } get done() { return this.animation.done; } update(timeDelta) { this.animation.update(timeDelta); } render(ctx) { this.animation.draw(ctx, this.x, this.y); } }

하는 일이 간단하니 코드도 간단하다 히힣. 그리고 이제 이 이펙트들을 관리하는 이펙트 관리자 클래스를 만들자. 이펙트 관리자는 아이템 관리자와 하는 일이 거의 비슷하다. 이펙트를 생성하고, 업데이트해주고 그려주다가 다 끝난 이펙트는 제거하면 된다.

class EffectManager extends GameObject { constructor() { super(); this.effects = []; this.img = new Image(); this.img.src = "http://web.lazygyu.net/test/whip/images/eclipse_sprites.png"; this.spriteSheet = new SpriteSheet(this.img, SpriteDefs.effect); } update(timeDelta) { super.update(timeDelta); this.effects = this.effects.filter((e) => { e.update(timeDelta); return !e.done; }); } render(ctx) { super.render(ctx); this.effects.forEach((e) => { e.render(ctx); }); } create(type, x, y) { let anim = new Animation(this.spriteSheet, AnimationDefs.effect[type], { loop: false }); let ef = new Effect(anim, x, y); ef.init(); this.effects.push(ef); } }

그동안 열심히 해왔으니까 이젠 왠만한 코드는 간단하게 구현된다. 넘나 편리한것! 이렇게 관리자 클래스를 만들어줬으면 GameScene에 기존에 하던대로 센스있게 추가해주자.

// GameScene.constructor 에 추가 this.effectManager = new EffectManager(); this.children.push(this.effectManager); this.character.effect = this.effect.bind(this); // 캐릭터가 이펙트를 주로 생성하므로 전달해주자

이제 필요한 곳에 이펙트를 쑉쑉 추가해보자! 우선 우리의 캐릭터가 줄을 거는 동작에는 중간과정이 젠젠 없어서 줄이 허공에서 생성되는 것처럼 보이는데, 이걸 이펙트 하나로 센스있게 해결해보자! 덩달아서 줄에서 점프할 때도 가벼운 이펙트를 넣어주자.

// Character.setPivot 에 추가 this.effect("clap", point.x, point.y); // 이건 줄을 거는 쪽에 this.effect("jump", this.x, this.y); // 이건 줄을 놓는 쪽에

그리고 각 아이템을 먹을 때도 적절한 이펙트를 추가하도록 하자.

// Character.gotItem에 추가 this.effect("jump", this.x, this.y); // 이건 점프 this.effect("mana", this.x, this.y); // 이건 마나 물약 this.effect("coin", this.x, this.y); // 이건 동전

자석 먹을 때는 별다른 이펙트는 필요 없으니 무시하면 된다. 그리고 게임을 실행해보면 전보다 훨씬 보기 좋아진 것이.....느껴지나? 모르면 할 수 없고 헤헿

소리

아직까지 우리의 게임은 뭔가 허전하다. 뭐 때문일까? 바로 소리 때문이지! 이제 슬슬 사운드를 첨가해보도록 하자.

HTML5에서 소리는 어떻게 내면 되지?

외부 라이브러리를 쓰면 된다....고 하면 너무 무책임하니까 일단 뭐라도 있어보이게 늘어놔보도록 하자. HTML5 스펙에는 웹 오디오 API가 있다. 이걸 사용하면 여러가지를 상세하게 제어해서 소리를 내줄 수 있...지만 귀찮다. 귀찮고 브라우저마다 구현체가 다르거나 canvas보다도 지원 브라우저가 더 적다는 문제가 있다. 그러니까 우리는 간단한 방법을 쓰도록 하자.

HTML5에는 라는 태그가 있다. 이 녀석은 옛날에 embed태그 같은걸로 음악을 넣던 것과 흡사한 방식으로 음악 파일을 재생할 수 있는데, 게다가 HTML5 표준 스펙이기 때문에 크로스 브라우징 염려도 확 줄어드는 장점이 있다.

그럼 간단하게 소리를 관리하는 사운드 관리자를 만들어보도록 하자. 이녀석은 게임에 사용되는 소리를 모조리 관리하고 요청에 따라 사운드를 재생하거나 정지하는 역할을 한다.

class SoundManager { constructor(){ this.sounds = {}; this.enable = true; this.soundFiles = ['whip', 'jump', 'highjump', 'coin', 'gameover', 'potion', 'bgm']; this.soundFiles.forEach((v)=>{ this.sounds[v] = document.createElement("audio"); this.sounds[v].src = "sounds/" + v + ".mp3"; }); this.sounds.bgm.volume = 0.3; // 브금은 효과음보다 좀 작게.. this.sounds.bgm.addEventListener("ended", (v)=>{ // 브금이 끝나면 자동으로 다시 반복해서 재생하자 this.sounds.bgm.currentTime = 0; this.sounds.bgm.play(); }); } init(){ this.bgm.play(); } stop(name){ this.sounds[name].pause(); this.sounds[name].currentTime = 0; } bgmStart(){ this.stop('bgm'); this.sounds.bgm.play(); } bgmStop(){ this.sounds.bgm.pause(); } play(name){ if( this.sounds[name]){ this.stop(name); this.sounds[name].play(); } } toggle(){ this.enable = !this.enable; this.soundFiles.forEach((v)=>{ this.sounds[v].volume = this.enable?((i=='bgm')?0.3:1):0; }); } distructor(){ this.soundFiles.forEach((v)=>{ this.soundFiles[v].pause(); this.soundFiles[v] = null; }); } }

위 소스를 보면 알겠지만 올바른 경로에 whip.mp3, jump.mp3, highjump.mp3, coin.mp3, gameover.mp3, potion.mp3, bgm.mp3 등의 파일들이 있어야 한다. 안타깝지만 이 파일들은 내가 맘대로 제공해줄 수 없으니 각자 알아서 아무 사운드나 구해서 넣도록 하자. 저작권법의 철퇴를 맞지 않게 조심해서..

어쨌든 이렇게 만들어진 사운드관리자를 GameScene에 추가하도록 하자. 다만 사운드관리자는 GameObject가 아니니까 children에는 추가하지 말아야 한다는 걸 잊지 말자.

// GameScene.constructor 에 추가 this.soundManager = new SoundManager(); // 소리를 내는 건 캐릭터니까 캐릭터에게 소리를 낼 수 있게 해주자 this.character.sound = this.soundManager.play.bind(this.soundManager);

이건 기우에서 하는 말인데, 이 게임은 소리를 내는 주체나 이펙트를 생성하는 주체가 캐릭터 하나 뿐이니까 이렇게 하는거고, 많은 객체들이 각자 소리를 내거나 이펙트를 생성할 수 있는 게임을 만들 때는 이렇게 하면 안된다. 로직도 꼬이거니와 메모리도 조지고 최적화에도 안좋....지만 지금은 그딴거 알 게 뭐야 그냥 가자 헤헿

이제 우리는 소리를 낼 수 있다! 캐릭터가 소리를 내야 하는 부분마다 맘껏 넣어주자. 먼저 아이템 먹는 소리들을 넣어주자.

//이제 Character.gotItem 은 이런 모양일것이다. gotItem(item) { switch (item) { case "jump": this.pivot = null; this.force.y -= 8; this.force.x += 10; this.mp = Math.max(10, this.mp + 1); this.effect("jump", this.x, this.y); this.sound("highjump"); //소리! break; case "mana": this.mp = Math.max(10, this.mp+3); this.effect("mana", this.x, this.y); this.sound("potion"); // 소리!! break; case "magnet": this.magnet = 3; break; case "coin": this.money += 100; this.effect("coin", this.x, this.y); this.sound("coin"); //소소소소리!! break; } }

그리고 채찍 휘두르는 소리와 점프하는 소리도 넣자!

// setPivot 은 이런 모양이겠지? setPivot(point) { if (this.pivot === null && this.mp > 0) { this.mp--; this.pivot = point; this.pLen = Math.distance(this, this.pivot); this.position = { x: this.x - this.pivot.x, y: this.y - this.pivot.y }; this.angle = Math.angle({ x: this.x, y: this.y }, this.pivot); this.accel = (-1.3 * (this.force.x + this.force.y) / this.pLen) * Math.sin(this.angle); this.effect("clap", point.x, point.y); this.sound("whip"); // 쨘 소리! this.update(0); } else if(this.pivot !== null) { this.pivot = null; this.pLen = 0; this.position = null; this.angle = 0; this.accel = 0; this.effect("jump", this.x, this.y); this.sound("jump"); // 여기도 소리!! this.update(0); } }

그리고 이제 게임을 실행해보면 배경음악과 효과음이 얼마나 게임에 큰 영향을 미치는지 확 느낄 수 있을거다! 헤헿

점수 관리

이 게임은 점수를 많이 내는 게 목표다. 그러니까 점수를 잘 저장해둬야 한다. 이번에도 마찬가지로 점수 관리 클래스를 만들어보자. 사실 꼴랑 점수 관리하는 데 관리자 클래스가 필요하진 않지만 나중에 페이스북 랭킹을 붙이기 편하도록 미리 코드를 분리해둔다는 심정으로...

class ScoreManager{ constructor(){ this.highscore = localStorage.getItem("highscore") || 0; this._score = 0; } reset(){ this._score = 0; } save(){ if( this.highscore > 0 ){ localStorage.setItem("highscore", this.highscore); } } set score(v){ this._score = v; if( this._score > this.highscore ) this.highscore = this._score; } get score(){ return this._score; } }

그리고 늘 하던대로 GameScene에다가 추가해주고..

// GameScene.constructor this.scoreManager = new ScoreManager();

init에서는 reset을 호출해주자

// GameScene.init this.scoreManager.reset();

그리고 update에서는 현재 점수를 넣어주면 되겠지?

// GameScene.update // 우리는 10픽셀을 1미터로 취급하니까 아래처럼 10으로 나눠주자. // 기본값인 100 을 빼주는 것도 잊지 말자 헤헿 this.scoreManager.score = Math.max(0, ((this.cameraX-100)/10)|0);

일단 이정도로 점수관리를 해두고 다음으로 넘어가자.

UI 만들기

우리는 게임 화면에 표시해줘야 할 것들이 많다. 점수도 표시해줘야 하고, 남은 MP도 보여줘야 하고, 최고기록도 보여줘야 하고, 현재 가진 코인도 보여줘야 한다. 우선 UI 클래스를 만들어보자.

class UI extends GameObject{ constructor(scoremanager, character){ this.scoreManager = scoremanager; //점수를 표시해주기 위해 점수 관리 객체를 받아두자 this.character = character; //MP를 표시해주기 위해 캐릭터를 받아두자. } }

생성자에서 눈치챘겠지만 요 녀석을 생성할 때는 저 둘을 넘겨줘야겠지? GameScene의 생성자에 아래 내용을 추가해주자.

// GameScene.constructor 에 추가 this.ui = new UI(this.scoreManager, this.character); //생성하고 this.children.push(this.ui); // 맨 마지막에 추가해준다.

구조상 GameScene.children은 뒤에 추가된 녀석일수록 화면에서 나중에 그려지기 때문에 UI는 반드시 마지막으로 추가되도록 해주자.

업데이트에서는 당장 따로 해줄 게 없을 거 같으니 우선 렌더에서 점수와 mp를 그려보...려고 하는데 그러려면 이번에도 역시 현재 카메라의 x 좌표가 필요하다. 이쯤되면 매번 x 좌표를 넣어주는 것도 지겨우니 모든 GameObject 에 공통으로 x 좌표를 넣어줄 방법을 생각해보자.

x 좌표 뿐만 아니라 각각의 게임 오브젝트들은 GameScene을 참조할 일이 생기기 쉽다. 이럴 바엔 모든 자녀 객체에 parent라는 멤버 변수를 두는게 어떨까? GaemObject 의 생성자(현재까지는 비어있을 것이다)에 아래처럼 벼수를 선언하자.

this.parent = null;

그리고 GameScene의 생성자 마지막 부분에 아래와 같은 줄을 추가하자.

this.children.forEach((ch)=>{ ch.parent = this; });

그럼 이제 각 게임객체들은 this.parent.cameraX 값을 참조해서 바로 카메라의 x 좌표를 가져올 수 있다. 그 동안 lastX 니 minX 니 하면서 각각 귀찮게 전달해주고 전달받던 코드들을 모조리 저걸로 연동시키도록 하자. (귀찮으니 여기서는 각자 알아서 잘 했겠거니 하고 넘어간다...) 그리고 UI.render에서 현재 점수를 그려보자.

render(ctx){ ctx.save(); ctx.translate(this.parent.cameraX-200, 0); // 현재 점수 표시 ctx.fillStyle = "white"; ctx.strokeStyle = "black"; ctx.strokeWidth = 2; ctx.font = "bold 40px verdana"; ctx.fillText(this.scoreManager.score + "m", 0, 540-35); ctx.strokeText(this.scoreManager.score + "m", 0, 540-35); // 외관선도 그려주자 ctx.restore(); } 

그리고 나면 이번엔 mp 잔량을 그려보자. 지난 게시물들을 잘 따라왔다면 원래 이미지에 mp 표시를 위한 부분이 있었던 걸 기억할 것이다. 우선 UI 생성자에서 해당 이미지를 가지고 있다가 필요한 부분에 이미지를 그려주도록 하자.

//UI.constructor 에 추가 this.img = new Image(); this.img.src = "images/eclipse_sprites.png";

MP를 표시하는 부분은 아래처럼 생겼다.

동그란 구슬 속에 파란 색이 차있는 형태인데, 당연히 파란색을 현재 mp 잔량에 맞춰서 그려주고 그 위에 테두리 + 광택이 표현된 이미지를 얹어주면 되겠지?

//UI.render 에 추가 // mp 잔량 표시 let mp = this.character.mp/10*60; // 이미지 세로 크기인 60에 대한 비율로 나타내기 위해서... if( mp > 0 ){ ctx.drawImage(this.img, 75, 395-mp, 60, mp, 540-65, 540-5-mp, 60, mp); } ctx.drawImage(this.img, 0, 330, 70, 70, 540-70, 540-70, 70, 70);

왜 스프라이트를 만들지 않고 직접 drawImage를 사용하느냐 하면... 이 게임에서 이미지를 잘라서 표시하는 부분은 딱 저기 한 군데 뿐이기 때문에 얘를 위해서 스프라이트 클래스를 고치기가 귀찮기 때문이다. 헤헿...

그리고 또 보여줘야 할 게 뭐가 있을까? 현재 캐릭터가 가진 소지 금액이 보여야 한다! 소지금액 앞에 이게 돈이라는 걸 알려주기 쉽도록 코인 아이템 모양도 그려주면 더 좋겠지? 우선 코인 스프라이트를 하나 만들자.

// UI.constructor 에 추가 this.coin = new Sprite(this.img, 300, 0, 40, 40, 0, 0);

만들었으면 그려주자

//UI.render 에 추가 // 현재 코인 잔액 표시 ctx.font = "bold 30px verdana"; ctx.fillStyle = "#ffb82f"; ctx.fillText(this.character.money, 35, 540-75); ctx.strokeText(this.character.money, 35, 540-75); this.coin.draw(ctx, 5, 440, {scale:0.625});

그리고 최고기록도 표시해주자.

//UI.render 에 추가 // 최고기록 표시 ctx.font = "16px verdana"; ctx.strokeText("HIGHSCORE " + this.scoreManager.highscore + "m", 0, 540-17); ctx.fillText("HIGHSCORE " + this.scoreManager.highscore + "m", 0, 540-17);

근데 최고기록을 보여줘봤자 우리 게임은 아직 죽질 않으니까 소용이 없다. 일단 임시로 캐릭터가 죽으면 다시 시작할 수 있도록 해보자. GameScene을 전체적으로 일단 바꿔보자.

일단 생성자에서 현재 게임 상태를 나타내는 변수를 추가하자.

// GameScene.constructor this.state = 0; // 0 : 게임중, 1 : 쥬금

당연히 init에서는 초기화를 해주고

// GameScene.init this.state = 0;

update 전체를 if로 감싸주자. 원래 많이 복잡해지면 업데이트와 렌더를 각 상태별로 분리해버리는게 편하지만, 별 거 없으니 그냥 if로 간다!

update(timeDelta, key) { if( this.state == 0 ){ this.elapsed += timeDelta; this.children.forEach((ch) => { ch.update(timeDelta, this.character); }); this.cameraX = Math.max(this.cameraX, this.character.x); this.scoreManager.score = Math.max(0, ((this.cameraX-100)/10)|0); this.itemManager.checkCollision(this.character); if (key === 32) { var tx = Math.cos(Math.PI / 4) * this.character.y + this.character.x; this.character.setPivot({ x: tx, y: 0 }); } if (this.character.y > 540 || this.terrain.isHit(this.character)) { this.terrain.fillStyle = "red"; this.state = 1; } else { this.terrain.fillStyle = "black"; } }else{ if( key === 32 ){ this.init(); } } }

이제 드디어 땅에 닿으면 게임이 멈추고 스페이스바를 누르면 다시 시작된다!

이번 편은 여기까지... 다음편에서는 아이템 상점도 만들고 게임오버 화면도 다듬고.. 게임을 전체적으로 예쁘게 다듬어보도록 하자. 이제 진짜 거의 다왔다!

See the Pen QdzgEW by LazyGyu (@lazygyu) on CodePen.

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