본문 바로가기

캔버스 게임만들기 3편『state구현하기』

by Recstasy 2019. 11. 4.


State는 말 그대로 현재 상태를 의미한다. 프로그래밍에서 의미하는 디자인패턴 'State'역시 상태를 표현하는 패턴방식이다. State패턴은 우리 주변에서 쉽게 찾아볼 수 있는 패턴이다. 게임으로 생각해보면, 주로 캐릭터의 상태를 나타낼 때 많이 사용된다. 


State패턴을 체험하려면, '정부24'와 같은 국가민원 포털사이트 같은 곳에서 등본서비스를 이용해보자. 정부24에서 주민등록등본을 뽑는 과정은 대략 다음과 같다. 


"서류작성 -> 승인(심사) -> 비용납부 -> 열람 -> 인쇄"   


사용자는 각 단계마다 현재 상태를 볼 수 있다. 이와 같은 단계를 구분해서 보여주고, 각 단계에 맞는 기능을 구현할 수 있는 패턴방식이 스테이트다.


1 Game클래스 

지난번에 작성한 Game()클래스와 Sprite()클래스의 전체 관계를 다시 정리해보자.

프로그램을 설계할 때, 메시지의 흐름을 따라가다보면 자연스럽게 전체 구조를 파악할 수

있다. 현재 설계하려는 구현은 '상태에 따라 사라지는 이미지'다. 이를 위해서는 다음과 같은

상황을 가정할 수 있다.


1] 웹 브라우저 :: (요청)캔버스를 실행하라 => (역할)게임 클래스 실행

2] 게임 클래스 :: (요청)게임을 구동하라 => (역할)리소스 불러오기

3] Sprite클래스 :: (요청)게임 리소스를 전송하라 =>(역할)게임리소스 실행 및 전송


1]과정은 지난번과 같다.


<script>

document.addEventListener("DOMContentLoaded", function(event){

const game = new Game();

});

</script>

<canvas id="game" width="480" height="320" style="border:1px solid black"></canvas>


중요한 부분은 2]이다. State방식은 2번 과정에서 사용된다. 시간의 흐름에 따라서 게임리

소스(이미지)의 상태가 달라지고, 이를 Sprite클래스에 요청한다. 게임 클래스의 속성부분은 지난 내용(캔버스 게임만들기2편)에서 refreshTime을 추가하는 부분만 바뀐다.


<script>  

  class Game{ 

constructor(){

    this.canvas = document.getElementById("game");

this.context = this.canvas.getContext("2d");

this.sprites = [];

this.spriteImage = new Image();

this.spriteImage.src = "https://firebasestorage.googleapis.com/v0/b/webgame-786ab.appspot.com/o/flower.png?alt=media&token=076f5c0a-6733-466f-9c6b-4e8ccc6b29af";

const game = this;

this.spriteImage.onload = function(){

game.lastRefreshTime = Date.now();

game.spawn();

game.refresh();

}


위의 코드에서 왜 Game.lastrefreshTime이란 속성이 생성되었을까?


게임엔진을 구현하는 코드에서 refresh메소드는 필수적이다. 게임 내부의 리소스는 시간의 흐름에 따라 지속적, 반복적으로 변하기 때문이다. 이를 정확히 이해하려면 '대형마트의 신선식품 코너'를 한번 생각해보면 된다. 


대형마트의 신선식품 코너에는 냉기가 지속적으로 뿜어져 나온다. 여기서 냉기를 내뿜는 콤프레셔는 반복적으로 돌아간다. 냉기가 한번만 뿜어져 나오고 끝나버리면 채소나 야채는 모두 시들어버리고, 신선식품이란 말이 무색해질 것이기 때문이다. 신선식품 기계의 구조는 다음과 같다.


1] 센서감지(온도변화

2] 콤프레셔에 메시지 요청

3] 관련 데이터 표시

4] 1 ~3번, 반복 수행 



게임의 메인코드도 위와 같다.


1] 변화감지(시간, 유저입력, 네트워크 등)

2] update()메서드에 데이터 전송 (update메서드 실행)

3] 변화된 상황을 랜더링(render메서드 실행)

4] 1~3번, 반복 수행 (refresh메서드 실행::requestAnimationFrame함수)



게임에 참여하는 엔티티(주체들)들의 요구사항을 시간의 흐름(초당 프레임)에 따라 체크

하고 update()메서드에 관련 데이터를 전달하는 과정을 requestAnimation함수를 통해

지속적으로 반복실행하는 설계가 게임패턴이다. 이와 관련된 refresh메서드를 살펴보자.


refresh() {

const now = Date.now();

const dt = (now - this.lastRefreshTime) / 1000.0;


this.update(dt);

this.render();


this.lastRefreshTime = now;

const game = this;

requestAnimationFrame(function(){ game.refresh(); });

}


refresh()메서드의 requestAnimationFrame()구문은 재귀구문이다. 위에서 냉장고가

끊임없이 냉기를 채워주는 동작을 반복하듯이, Game()클래스의 refresh()메서드 역시

변하는 시간에 따라 가변적인 상황을 update()메서드에 알려주고, render()메서드를 반복

적으로 구현하고 있다.


update(dt){

this.sinceLastSpawn += dt;

if (this.sinceLastSpawn>1) this.spawn();

let removed;

do{

removed = false;

for(let sprite of this.sprites){

if (sprite.kill){

const index = this.sprites.indexOf(sprite);

this.sprites.splice(index, 1);

removed = true;

break;

}

}

}while(removed);

for(let sprite of this.sprites){

if (sprite==null) continue;

sprite.update(dt);

}

}


update()메서드는 refresh()에서 바뀐 부분(시간)을 인자로 받아서 기존의 sinceLastSpawn값과 1초 차이가 나면(this.sinceLastSpawn>1) 이미지 객체를 실행한다. 여기서 중요하게 봐야할 점은 Game클래스 내의 update()메서드이다. 


Sprite클래스를 상속받은 sprite객체 역시 update()메서드를 가지는데, 이는 리스코프 치환의 법칙이 그대로 적용된 결과다. 하위 클래스의 메서드와 상위 클래스의 메서드 이름을 일치시켜 줌으로써 연동이 가능해진 것이다. 유연한 설계를 위해서는 상하위 클래스간의 메서드를 위와 같이 일치시켜주는 구현이 중요하다.


spawn(){

const sprite = new Sprite({

context: this.context,

x: Math.random() * this.canvas.width,

y: Math.random() * this.canvas.height,

width: this.spriteImage.width,

height: this.spriteImage.height,

image: this.spriteImage,

states: [ { mode:"spawn", duration: 0.5 }, {mode:"static", duration:1.5}, {mode:"die", duration:0.8} ]

});

this.sprites.push(sprite);

this.sinceLastSpawn = 0;

}


spawn()메서드는 게임에 따라 달라지는 부분이다. render(), update(), refresh()와 달리 게임의 리소스의 특징에 따라 이름이 달라질 것이다. 지금은 꽃 이미지를 불러오기 때문에 spawn()이라는 이름이 사용됐다. 하지만 게임 리소스를 불러온다는 본질적인 기능은 똑같다. 


render(){

this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);

for(let sprite of this.sprites){

sprite.render();

}

}

}


render()메서드는 게임 클래스 구현에 있어 항상 있어야하는 기능이다. 어떤 게임이건 최종적으로 오브젝트를 구현하는 역할이 있어야하기 때문이다. 위의 코드에서는 sprites배열에 쌓여있는 sprite를 하나씩 뽑아내서(let of구문) Sprite()클래스의 render()메서드를 실행하고 있다.



2 Sprite클래스 

Sprite클래스를 알아보기 전에 Game()클래스의 메시지 흐름을 생각해보자. Game()클래스는 게임에 필요한 사항들을 관리하기 위해 리소스를 모델에 요청해야한다. 화면에 캐릭터 및 각종 GUI들을 표현하려면 게임 전반의 리소스를 받아야하기 때문이다.


Sprite()클래스가 게임의 무기라고 생각해보자. Game()클래스는 캐릭터의 무기를 요청하고, Sprite()클래스는 요청한 무기사항을 Game()클래스에 전달해야 할 의무가 있다. 즉, Sprite()클래스는 MVC패턴의 'M(Model)'에 해당하며, 일종의 창고라 볼 수 있다. 


Game()관리자는 창고지기(Sprite클래스)에게 게임에 필요한 조건들을 요청하고, 창고지기는 요청받는 그대로 Game()클래스의 sprite변수에 전달한다. 그리고 Game()클래스는 전달받은 sprite를 sprites배열에 쌓아둔다. 아래의 Sprite()클래스 코드는 관리자와 창고지기간의 일종의 대화내용이라 생각할 수 있다.


class Sprite{

constructor(options){

this.context = options.context;

this.width = options.width;

this.height = options.height;

this.image = options.image;

this.x = options.x;

this.y = options.y;

this.states = options.states;

            this.state = 0;

this.scale = (options.scale==null) ? 1.0 : options.scale;

this.opacity = (options.opacity==null) ? 1.0 : options.opacity;

this.currentTime = 0;

this.kill = false;

}


Sprite()클래스는 Game()클래스가 요청한 options값을 받아서 속성으로 그대로 저장한다. Game()클래스가 요청한 내용을 Sprite()클래스는 정확하게 구현해야 할 의무가 있다. 이때 Sprite()클래스의 가장 중요한 의무는 '캡슐화'다. '캡슐화'를 다르게 해석하면, '단순화를 통한 효율성 증대'라고 표현할 수 있다. 


캡슐화는 객체지향 프로그램의 필수적인 부분인데, '캡슐화'는 왜 필요할까?


자동차를 생각해보자. 운전자는 새로운 모델의 신차가 출시될 때마다 운전면허 시험을 매번 치를 필요가 없다. 아무리 슈퍼카라 할지라도 '가속패달'의 기능은 '가속'이며, 기어는 '변속'을 담당하고, 브레이크는 '제동'을 한다. 


가속, 변속, 제동과 관련된 인터페이스의 디자인은 차마다 다르겠지만, 기능은 똑같다. 그래서 운전자는 신차를 운전하기 위해서 신차에 맞는 운전방법을 새롭게 배울 필요가 없다. 인터페이스는 동일하고, 내부 매커니즘만 다르기 때문이다. 프로그래밍에서는 이렇게 구조화된 효율성을 '추상화'라는 단어로 함축한다. 


사람은 동물과 달리 추상화를 통해 공통적인 부분을 패턴으로 동일하게 처리할 수 있다. 여기서 동일한 부분은 인터페이스이며, 복잡한 부분은 내부 구조다. 사용자는 인터페이스를 통해, 기능을 통제할 수 있기 때문에 인터페이스가 같다면 내부구조를 업그레이드하는 효율적인 방식으로 끊임없이 기능과 문제점을 개선할 수 있다. 이 부분이 바로 객체지향 프로그램의 가장 큰 장점이다. 그래서 모든 객체는 유저가 접근할 수 있는 공통적인 기능을 인터페이스로 만들고, 접근해서는 안 되는 부분은 감춰야 한다. 


만일 핸들대 옆에 달린 버튼을 통해 운전자가 엔진내부의 기계적 조절장치를 조절할 수 있다면 어떻게 될까? 아마도 수많은 잔고장과 함께 절대로 변하면 안 되는 수치값까지 변하면서 자동차 성능이 뒤죽박죽될 것이다. 


'캡슐화'는 인터페이스 이외의 부분에 접근할 수 없도록 설계하는 객체지향 방식이다. '캡슐화'덕분에 제작자와 사용자 모두 효율적으로 기계를 다룰 수 있다. 전자기기는 사용자가 모델명을 절대 변경할 수 없도록 '캡슐화'를 구현함으로써 각 기기에 대한 사후서비스를 신속하게 제공할 수 있다. 만일 사용자가 제품의 모델번호까지 접근해서 변경해버리면 아마도 대혼란이 발생할 것이다. 


게임 역시 캡슐화는 중요한데, 게임유저는 자신의 정보에 해당되는 부분까지 변경하고, 접근할 수 있다. 자바스크립트에서는 캡슐화를 위한 문법으로 'get', 'set'구문이 있다.


    set state(index){

        this.stateIndex = index;

        this.stateTime = 0;

    }

    

    get state(){

        let result;

        

        if (this.stateIndex<this.states.length) result = this.states[this.stateIndex];

        

        return result;

    }

    

update(dt){

this.stateTime += dt;

const state = this.state;

if (state==null){

this.kill = true;

return;

}

const delta = this.stateTime/state.duration;

        if (delta>1) this.state = this.stateIndex + 1;


switch(state.mode){

case "spawn":

//scale and fade in

this.scale = delta;

this.opacity = delta;

break;

case "static":

this.scale = 1.0;

this.opacity = 1.0;

break;

case "die":

this.scale = 1.0 + delta;

this.opacity = 1.0 - delta;

                if (this.opacity<0) this.opacity = 0;

break;

}

}


Sprite()클래스의 속성에는 분명히 'this.state = 0'이라 되어 있다. get state(){}구문이 없다면, console.log(new Sprite.state)값은 '0'이라 나와야 정상이다. 하지만 위에서는 get state()구문의 result변수값이 계산되며, 유저는 result값만 볼 수 있다. 


result변수에는 this.states배열속에 담긴 state객체가 있다. 따라서 update(dt){...}구문 속에 있는 'state'변수값에는 this.states의 정보가 담겨있다. 그래서 this.state = this.stateIndex + 1을 통해 this.stateIndex값을 delta값 증가에 따라 1씩 높일 수가 있다. 만일 캡슐화를 이해하지 않은 상태에서 해당 코드를 보면 상당히 헷갈릴 수가 있는데, this.state = '값'일 경우, set state(){...}가 진행되고, this.state를 단순히 요청할 경우, result값이 도출된다.


render() {

// Draw the animation

const alpha = this.context.globalAlpha;

this.context.globalAlpha = this.opacity;

this.context.drawImage(

   this.image,

   0,

   0,

   this.width,

   this.height,

   this.x,

   this.y,

   this.width * this.scale,

   this.height * this.scale);

this.context.globalAlpha = alpha;

}

}

</script>


마지막으로 Sprite()클래스의 render()메서드는 Game()클래스의 render()메서드와 이름을 일치시켰다.(리스코프 치환) 그 결과, Game()클래스의 sprites배열에 담겨있는 sprite객체의 render()메서드를 실행하는 것으로써 Sprite()클래스의 render()메서드를 동일하게 구현할 수 있다.(캔버스의 랜더링 기능)



3 전체코드


<script>  

  class Game{ 

constructor(){

     this.canvas = document.getElementById("game");

this.context = this.canvas.getContext("2d");

this.sprites = [];

this.spriteImage = new Image();

this.spriteImage.src = "https://firebasestorage.googleapis.com/v0/b/webgame-786ab.appspot.com/o/flower.png?alt=media&token=076f5c0a-6733-466f-9c6b-4e8ccc6b29af";

const game = this;

this.spriteImage.onload = function(){

game.lastRefreshTime = Date.now();

game.spawn();

game.refresh();

}


refresh() {

const now = Date.now();

const dt = (now - this.lastRefreshTime) / 1000.0;


this.update(dt);

this.render();


this.lastRefreshTime = now;

const game = this;

requestAnimationFrame(function(){ game.refresh(); });

}


spawn(){

const sprite = new Sprite({

context: this.context,

x: Math.random() * this.canvas.width,

y: Math.random() * this.canvas.height,

width: this.spriteImage.width,

height: this.spriteImage.height,

image: this.spriteImage,

states: [ { mode:"spawn", duration: 0.5 }, {mode:"static", duration:1.5}, {mode:"die", duration:0.8} ]

});

this.sprites.push(sprite);

this.sinceLastSpawn = 0;

}


update(dt){

this.sinceLastSpawn += dt;

if (this.sinceLastSpawn>1) this.spawn();

let removed;

do{

removed = false;

for(let sprite of this.sprites){

if (sprite.kill){

const index = this.sprites.indexOf(sprite);

this.sprites.splice(index, 1);

removed = true;

break;

}

}

}while(removed);

for(let sprite of this.sprites){

if (sprite==null) continue;

sprite.update(dt);

}

}


render(){

this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);

for(let sprite of this.sprites){

sprite.render();

}

}

}


class Sprite{

constructor(options){

this.context = options.context;

this.width = options.width;

this.height = options.height;

this.image = options.image;

this.x = options.x;

this.y = options.y;

this.states = options.states;

            this.state = 0;

this.scale = (options.scale==null) ? 1.0 : options.scale;

this.opacity = (options.opacity==null) ? 1.0 : options.opacity;

this.currentTime = 0;

this.kill = false;

}

    set state(index){

        this.stateIndex = index;

        this.stateTime = 0;

    }

    

    get state(){

        let result;

        

        if (this.stateIndex<this.states.length) result = this.states[this.stateIndex];

        

        return result;

    }

    

update(dt){

this.stateTime += dt;

const state = this.state;

if (state==null){

this.kill = true;

return;

}

const delta = this.stateTime/state.duration;

        if (delta>1) this.state = this.stateIndex + 1;


switch(state.mode){

case "spawn":

//scale and fade in

this.scale = delta;

this.opacity = delta;

break;

case "static":

this.scale = 1.0;

this.opacity = 1.0;

break;

case "die":

this.scale = 1.0 + delta;

this.opacity = 1.0 - delta;

                if (this.opacity<0) this.opacity = 0;

break;

}

}


       render() {

// Draw the animation

    const alpha = this.context.globalAlpha;

this.context.globalAlpha = this.opacity;

this.context.drawImage(

   this.image,

   0,

   0,

   this.width,

   this.height,

   this.x,

   this.y,

   this.width * this.scale,

   this.height * this.scale);

this.context.globalAlpha = alpha;

}

}

</script>

<script>

document.addEventListener("DOMContentLoaded", function(event){

const game = new Game();

});

</script>

<canvas id="game" width="480" height="320" style="border:1px solid black"></canvas>



댓글

최신글 전체

이미지
제목
글쓴이
등록일