HTML5 미니게임 개발 튜토리얼 1

이 글은 현재 "던전 앤 파이터"의 점검페이지에서 플레이할 수 있는 "Swing my baby"라는 게임을 HTML5 Canvas를 통해 만드는 과정을 설명한다

HTML5 미니게임 개발 튜토리얼

Swing my baby 를 통해 보는 HTML5 게임 개발(1)

이 글의 목표

이 글은 현재 "던전 앤 파이터"의 점검페이지에서 플레이할 수 있는 "Swing my baby"라는 게임을 HTML5 Canvas를 통해 만드는 과정을 설명한다. 해당 게임은 던전 앤 파이터가 정기점검을 하는 매주 목요일 새벽에 http://df.nexon.com 에서 플레이해 볼 수 있다. 혹시 나중에 다른데서도 플레이 가능해질지도 모르지만 일단 현재는 그렇다.

좋은 라이브러리들이 세상에는 많이 나와있지만, 간단한 게임이므로 별도의 라이브러리를 사용하지 않고 가능한한 직접 손으로 만들면서 진행해보도록 하자. 참고로 나는 발코더이기 때문에, 발로 쓴 것 같은 코드를 만나게 될테니 마음의 준비를 하면 더 좋다. 항상 귀차니즘으로 인해 블로그에는 '이런걸 만들어봤다' 라는 기록만 남기고 과정은 전혀 적지 않았는데, 연말이고 하니까 특별히 큰 맘 먹고 적어보자.

개발 과정

개발 과정중에 그래픽/사운드 리소스 제작이 빠져있는데, 이 부분은 각자 알아서 해보도록 하자... 뭐 이건 이 글에서 설명할 건덕지가 없다. 그래픽 리소스는 열심히 그리고, 사운드 리소스는 열심히 녹음하던지 찾던지 작곡하던지 하자.

게임 개괄

게임을 해봤거나 목표에 나온 화면을 보면 알겠지만, 이 게임은 기본적으로 원버튼 액션 장르로, 별도의 엔딩이 없는 기록 갱신형 게임이다. 게임의 주된 메커니즘은 스파이더맨이나 타잔이 줄타기하듯이 화면의 캐릭터가 줄에 매달려 진자운동을 통해 앞으로 얼마나 멀리 나아가느냐이다.

게임의 기본 구조 설계

게임 프로그램은 다른 웹 프로그램과 달리 무한 루프를 돌며 사용자의 입력이 없어도 능동적으로 계속해서 동작해야 한다. 따라서 일반적인 이벤트 드리븐 프로그램과는 코딩 스타일이 달라지게 된다. (물론 이벤트드리븐 프로그램들도 보통 내부적으로는 무한 루프를 돌고 있지만..) 이 글에서 만들어볼 게임의 기본 구조는 먼저 장면(흔히 Scene 이라고 부르곤 한다)을 기반으로 한다. 장면은 예를 들면 "타이틀 장면", "게임 장면", "게임 결과 장면"등이 된다. 각 장면들은 여러 게임 객체들을 가지고 있다. 예를 들어 게임 장면의 경우에는 캐릭터 객체, 아이템 객체, 점수 표시 객체, 배경 객체, 지형 객체등이 있을 수 있을 것이다. 게임 총괄 객체는 초당 60회를 기준으로 각 장면들에 대해 "갱신"과 "화면 그리기"를 요청하고, 이러한 요청을 받은 장면들은 마찬가지로 자신이 가지고 있는 게임 객체들에게 "갱신" 및 "화면 그리기" 요청을 보내게 된다. 각 게임 객체들은 각자의 논리에 맞춰서 상태를 갱신하고 화면에 정해진 내용을 그리게 된다. 그럼 이런 내용을 어떻게 코드로 표현하면 좋을지 생각해보자. 제일 먼저 게임 자체의 기본이 될 HTML 파일을 하나 만들자.
<canvas id="canv" width="540" height="540"></canvas> 
<script type="text/javascript"> 
  // 여기에 앞으로 게임을 작성하면 된다. 
</script>

게임 총괄 객체

게임 전체를 관리하는 총괄 객체를 만들어보자. 게임 객체는 각 장면을 관리하고, 장면간의 전환을 처리하고, 게임 루프를 작동시키는 기능이 필요하다. 이런 역할을 하는 기본적인 뼈대를 아래처럼 작성해보자.
class Game{
  // 생성자
  constructor(_canv){
    this.canvas = _canv;
    this.ctx = this.canvas.getContext('2d'); // 2d 컨텍스트를 저장한다
    this.scenes = []; // 장면들을 관리하는 스택

    // 시간 관리용 멤버 변수들
    this.now = 0;
    this.last = 0;
    this.elapsed = 0;
    this.timeDelta = 0;
  }

  // 게임 루프용 메소드
  // 이 메소드가 매 프레임(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.scenes.last().render(this.ctx); // 활성화된 장면을 그려줌
    }
    requestAnimationFrame(this.update.bind(this)); // 1/60초 후에 다시 실행
  }

  // 장면을 새로 전환할 때는 스택에 새로운 장면을 넣어주면 된다
  push(_scene){
    if( this.scenes.length>0 ) { 
       // 혹시 실행되고 있는 장면이 있을 경우 해당 장면에 정지 신호를 보내주고
      this.scenes.last().pause(); 
    }
    _scene.init(); // 새로 추가될 장면의 초기화 코드를 호출해준 뒤
    this.scenes.push(_scene); // 스택에 새 장면을 넣어준다.
  }

  // 장면의 실행이 끝나고 이전 장면으로 돌아갈 때는 스택에서 마지막 장면을 뽑아주면 된다.
  pop(){
    if( this.scenes.length === 0 ) return null; // 뽑을 장면이 없으면 암 것도 안하면 된다.
    var sc = this.scenes.pop();
    sc.destroy(); // 각 장면이 끝날때 처리해줄 내용이 있을 수 있으니 호출해주자.
    if( this.scenes.length > 0 ) {
      // 아직 장면 스택에 장면이 남아있다면 마지막 장면이 다시 활성화되어야 한다.
      // 활성화 신호를 보내주자.
      this.scenes.last().resume();
    }
    // 그리고 어디다 쓰고 싶을 수도 있으니까 뽑아낸 장면을 반환한다.
    return sc;
  }
}
위 내용을 보면 이제 게임 장면이 공통으로 구현해야 할 기본적인 인터페이스가 대충 그려질 것이다. 그려지면 뭐다? 만들어야지! 그치만 그 전에 아래 내용을 script 맨 위쪽에 추가하자.
Array.prototype.last = function(){
    return (this.length > 0)?this[this.length-1]:null;
}

게임 장면 객체

게임 장면 객체는 기본적으로 위에 나온 init, pause, resume, update, render, destroy 메소드를 구현해야 한다. 그 외에 자신에게 등록된 자식 객체들도 관리해줘야 한다. 그럼 이 내용을 간단히 구현해보자.
class Scene {
  constructor(){
    this.children = []; // 자식 객체들을 보관할 컨테이너
    this.elapsed = 0; // 이 게임 장면에서 경과한 시간
  }

  init(){
    // 일반적으로는 할 일이 없으니 냅두자
  }  

  update(timeDelta){
    // 매 프레임 상태 업데이트를 처리하는 메소드
    this.elapsed += timeDelta;
    this.children.forEach((child)=>{ child.update(timeDelta); }); // 자녀 객체들의 업데이트를 호출
  }

  render(ctx){
    // 화면에 장면을 그리는 메소드. ctx 는 캔버스의 2d 컨텍스트 객체가 된다.
    // 공통으로 하는 일은 그냥 단순히 전체 자식객체를 그려주는 정도면 된다.
    this.children.forEach((child)=>{ child.render(ctx); });
  }

  pause(){
    // 여기도 비워둠
  }

  resume(){
    // 마찬가지로 비워둠
  }
}
이제 각 필요한 장면들은 이 Scene 클래스를 상속받아서 구현하면 깔끔!

기본 게임 장면 객체 만들기

그럼 이제 개발중에 계속해서 사용될 게임 장면 객체를 만들고 이 객체가 동작하도록 해보자. 먼저 아래와 같이 GameScene을 만들자.
class GameScene extends Scene {
  constructor(){
    super();
  }

  init(){

  }

  update(timeDelta){
    super.update(timeDelta);
  }

  render(ctx){
    super.render(ctx);
    ctx.save();
    ctx.fillStyle = "blue";
    ctx.font = "bold 40px verdana sans-serif";
    ctx.fillText("Hello Canvas!", 100, 100);
    ctx.font = "20px sans-serif";
    ctx.fillText(this.elapsed, 0, 20);
    ctx.restore();
  }
}
그리고 스크립트 태그 맨 아래에 아래 내용을 추가하자
var game = new Game(document.getElementById('canv'));
game.push(new GameScene());
game.update();
여기까지 진행한 내용과 결과는 아래에서 확인해보자.

화면에 그림 표시하기

게임을 만들려면 뭐니뭐니해도 화면에 그림을 표시할 수 있어야한다. 그러면 HTML5에서는 이걸 어떻게 하면 되느냐? 그걸 이제부터 살펴보자.

이미지?, 스프라이트?, 애니메이션?

먼저 간단히 용어를 정리하고 넘어가자. 그림 하나는 그냥 이미지라고 부르도록 하자. 그리고 스프라이트는 뭐냐? 그건 화면에 표시되는 정적인 이미지를 말한다. 예를 들어서 화면상에 보이는 캐릭터라던지 아이템 그림 같은 것들을 스프라이트라고 보면 된다. 옛날에는 스프라이트 = 투명한 부분이 있는 이미지로 간단히 정리되던 시절도 있었다. 하지만 지금 우리는 화면에 표시될 모든 이미지를 스프라이트로 취급할 예정이므로 그냥 화면에 나오는것들 = 스프라이트, 라고 생각하도록 하자. 애니메이션은 여러가지 정의가 있겠지만 이 글에서의 애니메이션은 특정한 스프라이트들을 정해진 시간과 순서대로 바꾸어가면서 표시하는 객체로 정의하도록 하자. 그러므로 애니메이션 하나는 각 프레임에 해당하는 스프라이트와 그 외 정보를 포함하게 된다. 그니까 이런 느낌?

스프라이트

그러면 먼저 기본이 될 스프라이트 클래스를 만들어보자. 생성자는 아래와 같다.
class Sprite {
  constructor(image, sx, sy, sw, sh, ox, oy){
    this.img = image; // 원본 이미지
    this.sx = sx; // 이미지 내에서 스프라이트의 x 좌표
    this.sy = sy; // 이미지 내에서 스프라이트의 y 좌표
    this.sw = sw; // 스프라이트의 가로 크기
    this.sh = sh; // 스프라이트의 세로 크기
    this.ox = ox||0; // 스프라이트의 중심점 x
    this.oy = oy||0; // 스프라이트의 중심점 y
  }
}
하나의 이미지 내에 여러개의 스프라이트를 넣을 수 있도록 스프라이트를 이미지내의 일부분으로 사용하기 위한 멤버 변수들이 있는 것을 확인할 수 있다. oxoy가 뜬금없이 뭔지 의아할 수 있는데, 이건 스프라이트의 중심을 정해주는 변수다. 스프라이트를 회전하거나 크기를 조절할 때 기준점이 되는 위치가 필요하기 때문이다. 또, 이미지를 화면에 찍을 때 XY 좌표 부분에 해당 기준점이 위치하도록 찍어주는 역할도 한다. 위 그림과 같이 칼 스프라이트를 캐릭터의 손에 맞춰서 찍어야 한다거나 하는 경우에 기준점이 손잡이로 되어있으면 위치를 맞추기가 편하겠지? 이럴 때를 대비한 변수라고 생각하면 된다. 그럼 이런 것들을 반영해서 화면에 스프라이트를 찍는 메소드를 만들어보자.
draw(ctx, x, y, opt){
  // ctx : 캔버스 컨텍스트
  // x, y : 찍고자 하는 화면 좌표
  // opt : 기타 옵션들 (크기 조절, 회전 등)
  ctx.save();
  // 크기 조절에 별다른 값이 지정되어있지 않으면 크기를 조절하지 않는다.
  let scale = (opt&&opt.scale)||1;
  ctx.translate(x, y); //화면 기준점을 표시 좌표로 이동해서
  if( opt&&opt.rotate ) { ctx.rotate(-opt.rotate);  } // 회전 각도가 있다면 회전해주고
  // 원본 이미지에서 스프라이트만큼 잘라내서
  // 크기 조절에 맞춰서 찍어준다.
  ctx.drawImage(this.img, this.sx, this.sy, this.sw, this.sh,
               -this.ox * scale, -this.oy * scale, this.sw*scale, this.sh * scale);

  ctx.restore();
}
쨘! 이렇게 하면 이제 스프라이트를 화면에 찍을 수 있다.

스프라이트 시트

이미지 하나에 스프라이트는 한두개가 아닌데 이걸 언제 일일히 생성하고 전부 변수에 넣어서 관리할 수는 없는 일이다. 이 스프라이트들을 모아둔 걸 스프라이트 시트(sprite sheet)라고 부른다. 관리와 로딩의 편의를 위해서 스프라이트 시트를 만들어 보자. 스프라이트 시트에 필요한 기능은
  1. 스프라이트들을 한꺼번에 로드할 수 있어야 한다.
  2. 로드된 스프라이트를 바로바로 참조할 수 있어야 한다.
이 정도다. 간단하니까 코드도 간단해지겠지.
class SpriteSheet {
  constructor(image, def){
    // image는 스프라이트들이 들어있는 이미지
    // def 는 스프라이트의 정의가 모여있는 배열
    this.img = image;
    this.sprites = [];

    def.forEach((d)=>{
      // 정의에 맞춰서 스프라이트를 생성하고
      let spr = new Sprite(this.img, d.x, d.y, d.w, d.h, d.origin.x, d.origin.y);
      this.sprites.push(spr); // 생성된 놈을 배열에 넣어둔다.
    });
  }

  get(idx){
    return this.sprites[idx]; // 그냥 배열에서 해당 번호의 스프라이트를 반환하면 끝.
  }
}
이제 간단하게 스프라이트들을 로딩할 수 있다. 예를 들면 이런 식으로..
const spriteDef = [
  {x:0, y:0, w:50, h:50, origin:{x:25,y:25}},
  {x:50, y:0, w:50, h:50, origin:{x:25,y:25}},
  {x:100, y:0, w:50, h:50, origin:{x:25,y:25}}
];
const image = new Image('image.png');

var sheet = new SpriteSheet(image, spriteDef);
깔-끔!

애니메이션

이제 스프라이트들을 모아서 애니메이션을 만들어야 한다. 모으는거야 스프라이트시트로 해결했고, 나머지 문제를 생각해보자. 애니메이션은 기본적으로 프레임의 연속이다. 각 프레임에 필요한 정보는 표시할 스프라이트와 해당 프레임의 지속 시간이다. 애니메이션 자체에 필요한 정보는 애니메이션이 계속 반복되는지 아닌지 여부 정도면 된다. 그럼 아래와 같은 코드가 간단히 나온다.
class Animation {
  constructor (_sheet, defs, opt){
    this.elapsed = 0; //경과 시간
    this.curFrame = 0;
    this.sprites = _sheet;
    this.frames = defs;
    this.done = false;
    this.duration = defs.reduce((p,v)=>{ return p + v.duration; }, 0); // 각 프레임의 시간의 합
    this.loop = (opt&&opt.hasOwnProperty('loop'))?opt.loop:true;
  }

  clone() {
    // 같은 애니메이션이 동시에 여러개 화면에 표시되어야 하는 경우에는
    // 애니메이션 객체를 복제해서 써야 한다.
    // 이를 위한 유틸 메소드
    return new Animation(this.sprites, this.frames);
  }

  reset() {
    // 애니메이션을 처음으로 되돌리는 메소드.
    // 언젠가는 쓸모가 있을것!
    this.elapsed = 0;
    this.curFrame = 0;
  }

  get current() {
    // 현재 화면에 표시되는 프레임의 스프라이트를 반환하는 함수.
    return this.sprites.get(this.curFrame);
  }

  update (timeDelta){
    // 경과 시간에 따라 애니메이션을 업데이트한다.
    if( this.done ) return;
    this.elapsed += timeDelta;
    if( !this.loop && this.elapsed > this.duration ){
      // 반복되지 않는 애니메이션인데 끝까지 재생된 경우
      // 재생을 멈추고 마지막 프레임으로 고정
      this.done = true;
      this.curFrame = this.frames.length-1;
    }else{
      let idx = 0, sum = 0;
      while(true){
        sum += this.frames[idx].duration; // 각 프레임의 경과시간을 더해서
        if( sum >= this.elapsed ) break; // 현재 경과 시간보다 크거나 같으면 이 프레임으로 결정
        idx+=1;							 // 아니면 다음 프레임으로..
        idx%=this.frames.length;		 // 다음 프레임이 없으면 처음으로 돌아가자
      }
      this.curFrame = idx;
    }
  }

  draw (ctx, x, y, opt){
    // 스프라이트를 그리는 거랑 동일한 형태인 이유는 바로
    this.sprites.get(this.frames[this.curFrame].frame).draw(ctx, x, y, opt);
    // 스프라이트를 그리는거니까 그렇다! 헤헿
  }
}
이제 애니메이션 객체까지 만들었으니 화면에 뭘 그릴 수 있는 상태가 대충 됐다 헤헿.. 물론 아직 갈 길이 멀지만.. 뭐 어떻게든 되겠지.

캐릭터 만들기

이제부터 드디어 게임같은 걸 만드는 시점이 왔다. 나는 되는대로 눈에 보이는것부터 만들어서 고쳐나가는 걸 좋아하니까 다함께 캐릭터부터 만들어보도록 하자. 쭉 잘 읽어왔다면 이 게임 구조상 캐릭터나 아이템등의 모든 것들은 Scene 아래에 포함되는 '게임 객체'로 분류한 것을 알고 있을 것이다. 나는 이런 상속 구조를 싫어하긴 하지만 이게 귀차니즘을 덜어주므로 우선 '게임 객체' 클래스를 정의하도록 한다.
class GameObject{
  constructr(){  }
  init(){  }
  update(timeDelta) { }
  render(ctx){ }
}
보다시피 그냥 인터페이스다. 그럼 이제 캐릭터 클래스를 정의하도록 하자.
class Character extends GameObject {
  constructor(){
    // 생성자에서는 캐릭터에 사용될 이미지를 만들고 애니메이션들을 생성해야 한다.
    // 이 내용은 아래의 '캐릭터 애니메이션' 부분에서 다룰테니 기둘!
  }

  init(){
    // 여기서는 각종 변수를 초기화해야 한다.
    // 일단 현재 위치를 나타내는 변수를 만들어봤다.
    this.x = 0;
    this.y = 0;
  }

  update(timeDelta){
    // 캐릭터의 각종 상태를 변경하는 부분.
  }

  render(ctx){
    // 여기서 그려주면 된다.
    // 일단은 현재 위치에 반지름 20픽셀 짜리 빨간 원을 그리는 코드를 넣어보자.
    ctx.save();
    ctx.translate(this.x, this.y);
    ctx.fillStyle = "red";
    ctx.beginPath();
    ctx.arc(0, 0, 20, 0, 2*Math.PI); // 각도는 늘 라디안이라는 점을 잊지 말자!
    ctx.fill();
    ctx.restore();
  }
}
이렇게 클래스를 정의했으면 이걸 GameScene 에 추가해보도록 하자. GameScene 코드의 constructor 메소드 내에 아래 내용을 추가한다.
this.character = new Character();
this.children.push(this.character);
왜 바로 this.children.push(new Character());로 하지 않고 이렇게 별도로 멤버 변수로 할당하느냐면, 게임 장면에서 이 캐릭터에 직접적으로 접근할 일이 앞으로 많을 예정이기 때문이다. 사실은 children 에서 특정 키를 가진 객체를 바로 참조할 수 있는 방식으로 가도 괜찮지만, 귀찮으니까.. 그리고 GameScene.render 메소드내에서 super.render(ctx);를 제외한 나머지를 싹 지워주자. 여기까지 진행했으면 아래와 같은 화면이 나올 것이다. 화면 왼쪽 위에 빨간 동그라미가 살짝 보이는가? 그 동그라미가 우리가 이제부터 귀여워해줘야 할 우리의 캐릭터다! (실망스럽다...뭐야 저게)

캐릭터에 기본 물리 법칙 적용

그럼 이제 우리의 캐릭터를 움직여보도록 하자. 위 코드에 따르면 우리의 캐릭터는 그저 단순히 x/y 변수만 바꿔주면 위치가 바뀐다. 하지만 그냥 그렇게만 해서는 물리적인 움직임을 표현하기가 넘나 힘들다. 우선 init 메소드에 아래와 같은 내용을 추가하자.
this.force = {x:0, y:0};
이 변수는 내 캐릭터에 현재 가해지는 힘을 나타낸다. 앞으로 이 변수를 변화시키면 내 캐릭터가 거기에 맞게 움직이게 해주면 되는데... 그럼 이렇게 움직이는 코드를 update내에 넣어보자.
this.x += this.force.x;
this.y += this.force.y;
그럼 여기에 간단히 중력을 적용하는 코드도 추가하자. 일단 init에 중력을 정의하자. 왜 중력을 상수로 안 쓰고 변수로 하느냐면, 게임이라서 중력이 바뀔 수 있기 때문이다.
this.gravity = 10; //일단 대충 10으로 해보자.
그리고 update에 아까 추가한 두 줄 위에 아래 코드를 넣자.
this.force.y += this.gravity * timeDelta;
timeDelta는 지난 프레임 이후로 흐른 시간이 초단위로 들어있으므로, 지금 정의된 중력은 '초당 10픽셀의 가속도'라고 볼 수 있다. 다들 알고 있겠지만 힘이란건 가속도다. 여기까지 진행한 결과는 아래처럼 나온다. 한동안 새로고침 하면서 각자 자신이 만든 캐릭터가 떨어지는 모습을 음미하며 즐겨보자. (이 페이지를 새로고침하라는 말은 아니다...그러지마 나 가난해 ㅠㅠ) 그럼 글이 너무 길어지니까 이쯤에서 1편은 마무리... 2편에서 이어가보자!