현재 Stardew Dressup의 프리뷰 부분을 개발 중이다.
머리, 헤어스타일, 상의, 하의를 개발 완료하였고, 몸통과 소매 렌더링 부분을 설계 중이다. 팔 렌더링은 상의의 비트맵 색상에 따라 달라지는데, 이를 어떻게 구현하느냐가 가장 큰 문제였다.
스타듀밸리라는 게임의 캐릭터 렌더링은 FarmerRenderer 클래스에서 관장한다. 스타듀밸리 캐릭터 렌더링 과정을 유사코드로 설명하자면 다음과 같다.
플레이어의 머리 모양, 성별에 따라 다른 body 스프라이트시트를 불러온다.
body 스프라이트의 색을 플레이어의 데이터에 따라 변경시킨다.
body를 렌더링한다.
hair를 렌더링한다.
hats를 렌더링한다.
shirts를 렌더링한다.
pants를 렌더링한다.
Stardew Dressup은 mobx를 이용하여 상태관리 및 반응형 UI를 구현하고 있다. React 컴포넌트의 경우, 함수형 컴포넌트를 mobx-react 라이브러리가 제공하는 Observer 함수로 감싸주면 mobx observable이 변경되면 자동으로 변경 내용이 적용되어 반응형 UI를 구현할 수 있지만, pixi.js와 같은 서드파티 라이브러리는 불가능하다. 이에, mobx의 reaction 함수를 이용해, 사이드이펙트를 구현하고자 했다.
class ResponsiveSprite extends PIXI.Container
{
constructor()
{
super();
this.disposers = [];
}
makeReaction(observeData, react, fireImmediately=true)
{
let disposer = reaction(observeData, react, {fireImmediately});
this.disposers.push( disposer );
}
dispose(n)
{
if(typeof n === "number" && n>=0 && n<this.disposers.length)
{
this.disposers[n]();
this.disposers.splice(n, 1);
return;
}
for(let i=0; i<this.disposers.length; i++)
{
this.disposers[i]();
}
this.disposers = [];
}
destroy(option)
{
super.destroy(option);
this.dispose();
}
}
PIXI.Container를 상속한 뒤, disposers를 추가 프로퍼티로 선언하였다.
makeReaction 메소드는 mobx의 reaction을 추가하고, disposers에 dispose 함수를 추가하는 역할을 맡는다.
destroy 메소드를 상속한 뒤, destroy 메소드가 자기 자신이나 부모 컨테이너 오브젝트에 의해 호출되면 모든 mobx 리액션을 제거하도록 하였다.
이 ResponsiveSprite는 다음과 같이 사용할 수 있다.
class PantsSprite extends ResponsiveSprite
{
constructor(texture)
{
super();
this.baseTexture = texture;
this.sprite = new PIXI.Sprite();
this.prismatic = false;
this.addChild(this.sprite);
this.zIndex = 2;
}
initialize(farmer)
{
// change pants index
this.makeReaction(
()=>farmer.pantsBoundBox,
this.changeSprite.bind(this)
);
// change color
this.makeReaction(
()=>farmer.pantsTint,
this.changeColor.bind(this)
);
}
changeSprite(boundBox)
{
const texture = new PIXI.Texture(this.baseTexture, boundBox);
this.sprite.texture = texture;
}
changeColor(tint)
{
this.prismatic = (tint === "prismatic");
if(!this.prismatic) this.applyTint(tint);
}
applyTint(tint)
{
this.sprite.tint = tint;
}
}
initialize 메소드에서 makeReaction 메소드를 호출하면서, farmer의 pantsBoundBox나 pantsTint가 변경되면, 각각 changeSprite와 changeColor를 호출하도록 하였다.
머리카락이나, 옷 등은 원시 자료형을 가진 데이터의 변환을 기반으로 렌더링을 구현할 수 있었지만, 몸통 스프라이트는 색상이 상의 데이터의 비트맵 데이터를 기반으로 변화하고, 이는 상의 데이터의 인덱스 넘버와 색상 데이터, 사용하는 스프라이트시트 등에 영향을 받기에 설계에 난항을 겪고 있었다.
mobx observable이 변경되면 이를 기반으로 pixi.js에서 텍스처 데이터를 불러오고, 데이터를 다 불러왔으면 다른 mobx observable을 변경하는 방법을 떠올렸다.
다만, 이 설계는 문제가 있었다. viewer에서 controller의 역할인 데이터를 쓰는 과정이 추가되면서, viewer와 controller의 경계가 모호해지고, 스프라이트를 변경한 뒤 비동기로 텍스처 데이터를 가져오는 로직이 추가되면서 코드를 설계하고 이해하기 어려워지고 꼬일 것 같기 때문이었다. 추가적으로 Controller->Data Container->Viewer로 흐르는 데이터의 흐름이 역행한다는 문제도 있었다.
또한, mobx api의 reaction 문서에서는 다음의 원칙을 강조하고 있다.
reaction은 다른 observable을 업데이트하면 안 됩니다. reaction으로 다른 observable을 수정할 건가요? 만약 그렇다면, 일반적으로 업데이트할 observable은 computed 값으로 주석을 달아야 합니다. 예를 들어 todo 컬렉션이 변경된 경우 remainingTodos의 양을 계산하기 위해 reaction을 사용하는 것이 아니라, remainingTodos를 computed 값으로 주석 처리해야 합니다. 그러면 코드를 훨씬 더 명확하고 쉽게 디버깅할 수 있습니다. reaction은 새로운 데이터를 계산하는 것이 아니라, effect를 유발하는 용도로 사용되어야 합니다.
reaction은 독립적이어야 합니다. 코드가 먼저 실행되어야 하는 다른 reaction에 의존하나요? 이 경우 첫 번째 규칙을 위반했을 수 있으며, 의존하고 있는 reaction에 새로 생성하려는 reaction을 병합해야 합니다. MobX는 reaction이 실행되는 순서를 보장하지 않습니다.
이 설계는 사실상 reaction으로 다른 reaction을 업데이트하는 형태이기 때문에, 대체 설계를 찾는 것이 필요했다.
소매 색상을 구하기 위해 필요한 것은 상의의 텍스처 데이터인데, 이는 스프라이트시트가 변경될 때 직접적으로 바뀐다. 이에 착안해, 스프라이트시트를 불러올 때 모든 상의의 소매 기본 색상을 계산해서 컨테이너에 저장하고, 상의 인덱스와 색상이 변경될 때 컨테이너에 있는 색상 데이터를 기반으로 소매 색상을 계산한 뒤 몸통 스프라이트를 렌더링하는 방법을 생각해내었다.
이 방법은 reaction을 이용하는 viewer와 데이터를 조작, 계산하는 container의 역할에서 벗어나지 않고, 소매 색상 변경 과정을 controller의 영역으로 끌고 올라올 수 있다는 점에 의의가 있다.