DIYPlay
블로그

pixi.js기반 게임 프레임워크 만들기

DIYPlayer

·

3달 전

pixi.js
게임엔진
2d게임
게임프레임워크

 

pixi.js를 활용해서 게임 프레임워크를 만들어 보는 프로젝트 입니다.

pixi.js 소개

pixi.js

 https://pixijs.com/

pixi.js는 HTML 2D 렌더링 엔진(라이브러리)입니다. 웹페이지에서 다양한 2D 렌더링 효과를 쉽게 사용할 수 있게 해줍니다. 

 오픈소스로 개발되고 있으며 하루가 다르게 새로운 버전이 출시되고 있는 활동적인 프로젝트입니다. 지금은 WebGL을 베이스로 하고 있지만 WebGPU을 지원하는 버전도 곧 출시될 계획이라고 합니다. 새로운 버전이 출시되면 약간의 문법은 바뀔 수도 있겠지만 pixi.js가 추구하는 기본 컨셉을 익혀둔다면 최신 사양의 렌더링 기술과 향상된 성능을 이용할 수 있을 것입니다.

 pixi.js는 렌더링 이외에도 리소스 로드 및 관리, 마우스 입력을 통한 상호작용, 업데이트 루프 등 많은 기능을 제공해줍니다. 하지만 게임 전용 엔진이나 라이브러리가 아니라서 게임을 제작하기에는 살짝 아쉬운 면이 있습니다. 

 이 프로젝트에서는 게임 제작에 필요한 반복 적인 구조를 직접 프레임워크로 만들어 보면서 pixi.js의 기본적인 사용법을 익히고 활용하는 방법을 알아보겠습니다.

프로젝트 계획

가장 인기 많은 게임엔진 중 하나인 유니티의 컴포넌트 구조와 오브젝트의 라이프 싸이클을 따라 만들어 보는걸 목표로 합니다.

  1. 게임오브젝트: pixi.js 의 기본 오브젝트 단위인 Container를 확장해 게임 오브젝트와 비슷한 형태 만들기
     
  2. 컴포넌트 시스템: 필요하거나 추가되는 기능은 컴포넌트로 단위로 만들기
     
  3. 라이프싸이클: awake, onEnable, onDisable, update 등 게임오브젝트나 컴포넌트의 라이프싸이클 만들기
     
  4. 기타 필요한 기능 추가

 

0. pixi.js 시작하기

 프레임워크 제작에 앞서 pixi.js 의 기본적인 사용 방법을 알아보겠습니다.

 

CDN 으로 pixi.js 설치합니다.

<!-- CDN 을 사용한 pixi.js 추가 -->
<script src="https://pixijs.download/release/pixi.min.js"></script>

 

이미지를 추가하고 루프를 생성하는 간단한 예제 코드입니다.

//pixi.js app 생성
const app = new PIXI.Application({ width: 640, height: 360 });
document.body.appendChild(app.view);


//Sprite 객체 생성하고 stage에 추가
const sprite = PIXI.Sprite.from('https://pixijs.com/assets/bunny.png');
app.stage.addChild(sprite);


let elapsed = 0.0;

//루프 생성
app.ticker.add((delta) => {
  elapsed += delta;
  sprite.x = 100.0 + Math.cos(elapsed/50.0) * 100.0;
});

 

1. 게임 오브젝트 만들기

 pixi.js의 많은 렌더링 요소들은 Container 클래스를 상속 받습니다. Container 클래스는 트랜스폼 정보와 하이어라키 구조등 오브젝트를 효율적으로 관리하기 위한 사전 준비를 잘 갖추고 있습니다. Container 클래스를 확장하면 이 클래스를 상속받은 Sprite, Graphics, Text 등 pixi.js의 많은 렌더링 요소들에게도 확장을 적용할 수 있습니다. 

 자식 클래스가 많은 클래스를 확장해야 하고, 확장된 내용이 자식 클래스 들에게도 적용되어야 하므로 일반적인 상속 방법은 사용할 수 없습니다.  대신에 프로토 타입을 수정하거나 확장하는 방법을 사용하겠습니다.

//prototype을 이용한 함수 추가
PIXI.Container.prototype.추가함수 = function() {}

//기존 함수 오버라이딩
const originContainerDestroy = PIXI.Container.prototype.destroy;
PIXI.Container.prototype.destroy = function(...args) {
    originContainerDestroy.call(this, ...args);
    
    //추가 로직
}

 

 생성자를 오버라이딩 하여 객체가 생성될 때 여러 추가 작업을 하고 싶지만 prototype을 사용한 확장방법으로는 생성자를 오버라이딩 할 수 없습니다. 대신 초기화 함수를 만들고 stage에 객체가 자식으로 추가될 때 초기화 함수가 호출 되도록 하겠습니다.


let __goId_ = 0;
PIXI.Container.prototype.init = function() {
    if(this._isInit) return;
    
    this._isInit = true;
    this.name = this.name || 'gameObject';
    this.id = this.id || ++__goId_;

    this.on('childAdded', (child)=>{
        child.init();
    });
    this.on('added', ()=>{
    	
    });
    this.on('removed', ()=>{
    
    });
    
    for(let i = 0; i < this.children.length; i++) {
        this.children[i].init();
    }
}

  

class World extends PIXI.Container {
    constructor() {
        super();
        this.init();
    }
}

 사용법 코드는 다음과 같습니다.

const app = new PIXI.Application();
document.body.append(app.view);
app.stage = new World();

//앞으로 app.stage에 자식이 추가될때마다 추가된 객체의 init함수가 호출됨

 

2. 컴포넌트 시스템

 추가되는 기능들의 재사용성과 효율적인 관리를 위해 게임 오브젝트에 컴포넌트 시스템을 적용해보겠습니다.

 

 컴포넌트를 정의합니다.

let __compId_ = 0;

class Component {
    constructor(gameObject) {
        this.gameObject = gameObject;
        this.id = ++__compId_;
    }

    get name() {
        return Object.getPrototypeOf(this).constructor.name;
    }

}

 게임오브젝트에 컴포넌트 추가, 제거, 반환 기능을 추가합니다.

PIXI.Container.prototype.addComponent = function(comp) {
    this.components = this.components || {};
    this.componentArray = this.componentArray || [];
    const compName = comp.name;
    if(this.components[compName]) {
        return this.components[compName].component;
    }

    const _comp = new comp(this);
    this.components[compName] = {
        id: _comp.id,
        component: _comp,
    };
    this.componentArray.push(_comp);


    return _comp;
}

PIXI.Container.prototype.getComponent = function(comp) {
    const compName = comp.name;
    if(!this.components || !this.components[compName]) return;
    return this.components[compName].component;
}

PIXI.Container.prototype.removeComponent = function(comp) {
    const compName = comp.name;
    if(!this.components || !this.components[compName]) return;
    const _comp = this.components[compName];

    for(let i = 0; i < this.componentArray.length; i++) {
        if(this.componentArray[i].id === _comp.id) {
            this.componentArray.splice(i, 1);
            break;
        }
    }
    delete this.components[compName];
}

 

3. 라이프 싸이클 구현

 컴포넌트들의 동작을 구조화 해서 사용할 수 있도록 unity의 라이프싸이클을 모방해서 적용해 보겠습니다. 앞으로 추가될 컴포넌트들은 필요할 경우 아래의 함수를 정의해서 사용하면 됩니다.

 구현할 라이프 싸이클은 다음과 같습니다.

  • awake: 컴포넌트가 생성되고 첫 활성화 시점에 실행됩니다. 
  • onEnable: 컴포넌트가 첫 활성화나 비활성화 상태에서 활성화 되면 실행됩니다. ( awake 호출된 후에 실행됨)
  • start: 컴포넌트의 update 첫 프레임에서 실행됩니다.
  • update: 매 프레임마다 컴포넌트가 활성화 상태이면 실행됩니다.
  • onDisable: 컴포넌트가 활성화 상태에서 비활성화 되면 실행됩니다.
  • onDestroy: 컴포넌트가 제거될 때 실행됩니다.

컴포넌트에 라이프싸이클을 적용할 수 있게 다음과 같이 수정합니다.

class Component {
	//...

    _awake() {
        if(this._isAwake) return;
        this._isAwake = true;
        this.awake && this.awake();
        this._onEnable();
    }

    _start() {
        if(this._isStart) return;

        this._isStart = true;
        this.start && this.start();
    }

    _onEnable() {
        if(!this._isAwake) {
            this._awake();
            return;
        }
        this.onEnable && this.onEnable();
    }

    _update(dt) {
        if(!this._isAwake) return;

        if(!this._isStart) {
            this._start();
        }

        this.update && this.update(dt);
    }

    _onDisable() {
        if(!this._isAwake) return;
        this.onDisable && this.onDisable();
    }

    _onDestroy() {
        this.onDestroy && this.onDestroy();
        this.gameObject = null;
    }
}

 

컴포넌트의 라이프싸이클은 결국 게임오브젝의 라이프싸이클에 의해 결정되므로, 게임오브젝트도 일정한 라이프싸이클을 가지면서 컴포넌트들을 제어해야 합니다.

PIXI.Container.prototype.setEnable = function(enable) {
    if(this.visible !== enable) {
        this.visible = enable;
        if(!this.getEnable() || !this.getParentEnable()) return;

        if(this.visible) {
            this.onEnable();
        }
        else {
            this.onDisable();
        }        
    }    
}

PIXI.Container.prototype.getEnable = function() {
    return this.visible;
}

PIXI.Container.prototype.getParentEnable = function() {
    return (this.parent 
            && this.parent.getEnable() 
            && this.parent.getParentEnable()) 
        || false;
}

PIXI.Container.prototype.onEnable = function() {
    if(this._prevIsEnable === true || !this.getEnable()) {
        return;
    }

    this._prevIsEnable = true;
    if(this.componentArray) {
        for(let i = 0; i < this.componentArray.length; i++) {
            this.componentArray[i]._onEnable();
        }
    }

    for(let i = 0; i < this.children.length; i++) {
        this.children[i].onEnable();
    }  
}

PIXI.Container.prototype.onDisable = function() {
    if(!this._prevIsEnable || this.getParentEnable() && this.getEnable()) {
        return;
    }

    this._prevIsEnable = false;
    if(this.componentArray) {
        for(let i = 0; i < this.componentArray.length; i++) {
            this.componentArray[i]._onDisable();
        }
    }

    for(let i = 0; i < this.children.length; i++) {
        this.children[i].onDisable();
    }  
}

PIXI.Container.prototype.update = function(dt) {
    if(!this.getEnable()) {
        return;
    }

    if(this.componentArray) {
        for(let i = 0; i < this.componentArray.length; i++) {
            const comp = this.componentArray[i];
            comp._update(dt);        
        }
    }

    for(let i = 0; i < this.children.length; i++) {
        const child = this.children[i];
        child.update && child.update(dt);
    }    
}

PIXI.Container.prototype.addComponent = function(comp) {
    this.components = this.components || {};
    this.componentArray = this.componentArray || [];
    const compName = comp.name;
    if(this.components[compName]) {
        return this.components[compName].component;
    }

    const _comp = new comp(this);
    this.components[compName] = {
        id: _comp.id,
        component: _comp,
    };
    this.componentArray.push(_comp);

    if(this.getEnable() && this.getParentEnable()) {
        _comp._awake();
    }
    return _comp;
}

PIXI.Container.prototype.getComponent = function(comp) {
    const compName = comp.name;
    if(!this.components || !this.components[compName]) return;
    return this.components[compName].component;
}

PIXI.Container.prototype.removeComponent = function(comp) {
    const compName = comp.name;
    if(!this.components || !this.components[compName]) return;
    const _comp = this.components[compName];
    _comp.component._onDisable();
    _comp.component._onDestroy();
    for(let i = 0; i < this.componentArray.length; i++) {
        if(this.componentArray[i].id === _comp.id) {
            this.componentArray.splice(i, 1);
            break;
        }
    }
    delete this.components[compName];
}


let __goId_ = 0;

PIXI.Container.prototype.init = function() {
    if(this._isInit) return;
    this._isInit = true;
    this.name = this.name || 'gameObject';
    this.id = this.id || ++__goId_;

    this.on('childAdded', (child)=>{
        child.init();
    });
    this.on('added', ()=>{
        this.onEnable();
    });
    this.on('removed', ()=>{
        this.onDisable();
    });
    
    for(let i = 0; i < this.children.length; i++) {
        this.children[i].init();
    }
}

const originContainerDestroy = PIXI.Container.prototype.destroy;
PIXI.Container.prototype.destroy = function(...args) {
    originContainerDestroy.call(this, ...args);
    if(this.componentArray) {
        for(let i = 0; i < this.componentArray.length; i++) {
            const comp = this.componentArray[i];
            comp._onDisable();
            comp._onDestroy();
        }
    }    
}

 

4.  게임 겍체 만들기

 게임을 구성하는데 필요한 객체들을 전반적으로 관리하고 프레임워크의 진입점으로 사용할 게임 객체를 만들어 보겠습니다.

class Game {
    constructor(options = {}) {
        options.width = options.width || 640;
        options.height = options.height || 360;
        
        const app = new PIXI.Application(options);
        document.body.append(app.view);
        this.app = app;
        
        this._world = new World(this);
        app.stage.addChild(this._world);
        //this._camera = new Camera(this);
        
        app.ticker.add((d)=>{
            const delta = d/60;
            app.stage.update(delta);
        });        
    }
    
    //get camera() {
    //    return this._camera;
    //}
    
    get world() {
        return this._world;
    }
    
    get screen() {
        return this.app.renderer.screen;
    }
    
    get width() {
        return this.screen.width;
    }
    
    get height() {
        return this.screen.height;
    }
    
    clearWorld() {
        const prevWorld = this._world;
        this.app.stage.removeChild(prevWorld);
        prevWorld.destroy({chldren: true});
        //this._camera.reset();
        this._world = new World(this);
        this.app.stage.addChild(this._world);
    }
}

 

5. 카메라 기능 추가 및 월드 좌표

 카메라 기능을 구현해보겠습니다. 카메라는 다른 오브젝트에 자식으로 추가되지 않습니다. 트랜스폼 정보를 가지고 있다가 World의 트랜스폼이 업데이트 될 때 적용해줍니다.

class Camera extends PIXI.Container {
    constructor(game) {
        super();
        this.game = game;
        this.reset();
    }
    
    reset() {
        this.x = this.game.screen.width/2;
        this.y = this.game.screen.height/2;
    }
}

class World extends PIXI.Container {
    constructor(game) {
        super();
        this.init(game);
        this._tempMatrix = new PIXI.Matrix();
    }
    
    getParentEnable() {
        return (this.parent && this.parent.getParentEnable()) || this.getEnable();
    }
    
    updateTransform() {
        this._tempMatrix.identity();
        this._tempMatrix.scale(1/this.game.camera.scale.x, 1/this.game.camera.scale.y);
        this._tempMatrix.rotate(this.game.camera.rotation);
        this._tempMatrix.translate(this.game.camera.position.x , this.game.camera.position.y);
        this._tempMatrix.invert();
        this._tempMatrix.translate(this.game.width/2, this.game.height/2);
        
        if (this.sortableChildren && this.sortDirty) {
            this.sortChildren();
        }
        
        this._boundsID++;
        this.transform.setFromMatrix(this._tempMatrix);
        this.transform.updateTransform(this.parent.transform);
        this.worldAlpha = this.alpha * this.parent.worldAlpha;
        for (let i = 0, j = this.children.length; i < j; ++i) {
            const child = this.children[i];
            if (child.visible) {
                child.updateTransform();
            }
        }
    }
}

  

 pixi.js 에서는 마우스나, 터치 등 입력 이벤트가 발생했을 때 오브젝트들과 상호작용할 수 있습니다. 이 때 입력된 좌표는 화면 좌표만을 기준으로 합니다. 입력 이벤트가 발생했을 때 해당 위치의 월드 좌표를 반환받기 위해 다음 코드를 추가합니다. 

const inputEvents = [
    'pointercancel',
    'pointerdown',
    'pointerenter',
    'pointerleave',
    'pointermove',
    'pointerout',
    'pointerover',
    'pointerup',
    'pointerupoutside',
    'touchcancel',
    'wheel',
    'globaltouchmove'
]; 

PIXI.Container.prototype.init = function(game) {
	//...
	this.game = game;
	//...
	
	//pixi 입력 이벤트가 발생했을 때 월드 좌표를 추가
	//global : 화면 좌표
	//world  : 월드 좌표
	inputEvents.forEach((event) =>
	{
    	this.addEventListener(event, (e) => {
        	if(!e.data.world) {
            	e.data.world = new PIXI.Point();
        	}
        	this.game.world.worldTransform.applyInverse(e.data.global, e.data.world);
    	});
	});
}

 

프로젝트 바로가기

https://diyplay.co.kr/project/view/8

이전 글

DIY 에디터 API 소개

다음 글

A* 길찾기 알고리즘 DIY

댓글 0
    서비스 이용약관|개인정보 보호정책

    Copyright © DIYPlay All rights reserved.