pixi.js를 활용해서 게임 프레임워크를 만들어 보는 프로젝트 입니다.
pixi.js는 HTML 2D 렌더링 엔진(라이브러리)입니다. 웹페이지에서 다양한 2D 렌더링 효과를 쉽게 사용할 수 있게 해줍니다.
오픈소스로 개발되고 있으며 하루가 다르게 새로운 버전이 출시되고 있는 활동적인 프로젝트입니다. 지금은 WebGL을 베이스로 하고 있지만 WebGPU을 지원하는 버전도 곧 출시될 계획이라고 합니다. 새로운 버전이 출시되면 약간의 문법은 바뀔 수도 있겠지만 pixi.js가 추구하는 기본 컨셉을 익혀둔다면 최신 사양의 렌더링 기술과 향상된 성능을 이용할 수 있을 것입니다.
pixi.js는 렌더링 이외에도 리소스 로드 및 관리, 마우스 입력을 통한 상호작용, 업데이트 루프 등 많은 기능을 제공해줍니다. 하지만 게임 전용 엔진이나 라이브러리가 아니라서 게임을 제작하기에는 살짝 아쉬운 면이 있습니다.
이 프로젝트에서는 게임 제작에 필요한 반복 적인 구조를 직접 프레임워크로 만들어 보면서 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;
});
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함수가 호출됨
추가되는 기능들의 재사용성과 효율적인 관리를 위해 게임 오브젝트에 컴포넌트 시스템을 적용해보겠습니다.
컴포넌트를 정의합니다.
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];
}
컴포넌트들의 동작을 구조화 해서 사용할 수 있도록 unity의 라이프싸이클을 모방해서 적용해 보겠습니다. 앞으로 추가될 컴포넌트들은 필요할 경우 아래의 함수를 정의해서 사용하면 됩니다.
구현할 라이프 싸이클은 다음과 같습니다.
컴포넌트에 라이프싸이클을 적용할 수 있게 다음과 같이 수정합니다.
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();
}
}
}
게임을 구성하는데 필요한 객체들을 전반적으로 관리하고 프레임워크의 진입점으로 사용할 게임 객체를 만들어 보겠습니다.
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);
}
}
카메라 기능을 구현해보겠습니다. 카메라는 다른 오브젝트에 자식으로 추가되지 않습니다. 트랜스폼 정보를 가지고 있다가 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);
});
});
}