Stardew Dressup 개발일지(2)

Lybell·2022년 7월 11일
0
post-thumbnail

개발상황

현재 Stardew Dressup의 프리뷰 부분을 개발 중이다.
머리, 헤어스타일, 상의, 하의를 개발 완료하였고, 몸통과 소매 렌더링 부분을 설계 중이다. 팔 렌더링은 상의의 비트맵 색상에 따라 달라지는데, 이를 어떻게 구현하느냐가 가장 큰 문제였다.

스타듀밸리 캐릭터 렌더링 과정

스타듀밸리라는 게임의 캐릭터 렌더링은 FarmerRenderer 클래스에서 관장한다. 스타듀밸리 캐릭터 렌더링 과정을 유사코드로 설명하자면 다음과 같다.

  • 플레이어의 머리 모양, 성별에 따라 다른 body 스프라이트시트를 불러온다.

  • body 스프라이트의 색을 플레이어의 데이터에 따라 변경시킨다.

    • 해당 부분은 executeRecolorActions 메소드에 정의되어 있다.
    • 플레이어의 피부색 인덱스에 따라, body 스프라이트시트의 260, 261, 262번째 픽셀과 동일한 색상을 skinColors.xnb의 (x, 피부 인덱스)로 각각 대체한다.
    • body 스프라이트시트의 276번째 픽셀과 동일한 색상을 플레이어의 눈 색으로 대체한다.
    • body 스프라이트시트의 277번째 픽셀과 동일한 색상을 플레이어의 눈 색에서 brightness를 75 낮춘 값으로 대체한다.(바닐라 스프라이트에선 사용하지 않음)
    • body 스프라이트시트의 256, 257, 258번째 픽셀을 플레이어의 상의 스프라이트에서 추출한 색상 값으로 렌더링한다.
      • 해당 부분은 ApplySleeveColor 메소드에 정의되어 있다.
      • 착용중인 셔츠의 데이터가 sleeveless이면, 대체할 색상을 플레이어의 피부색으로 정의한다.
      • shirts 스프라이트시트를 불러온다. 이 스프라이트시트의 스프라이트는 8x8 사이즈를 갖고, 16행의 기본 컬러 스프라이트와 16행의 컬러 적용 스프라이트로 구성되어 있다.
      • shirt 스프라이트의 dark는 (0, 4)에서, mid는 (0, 3)에서, light는 (0, 2)에서 추출한다.
        • 다음 과정을 3번 반복하여, light, mid, dark 색상을 추출한다.
          • colored 스프라이트의 alpha값을 확인한다.
          • 만약 colored 스프라이트의 alpha값이 0이 아니라면,
          • 셔츠의 염색 값과 colored 스프라이트의 색상을 multiply한 값을 반환한다.
        • 만약 colored 스프라이트의 alpha값이 0이면, uncolored 스프라이트에서 추출한 값을 반환한다.
  • body를 렌더링한다.

    • 플레이어 동작 인덱스와 방향에 따라, 스프라이트시트에서 베이스와 소매를 렌더링한다.
      • 각 스프라이트는 16x32 사이즈를 가진다.
      • 앞은 0픽셀 y 오프셋을, 오른쪽은 32픽셀 y 오프셋을, 뒤는 64픽셀 y 오프셋을 가진다.
      • 왼쪽은 32픽셀 y 오프셋을 적용해서 렌더링한 뒤 좌우반전한다.
  • hair를 렌더링한다.

    • 추가 헤어 데이터를 불러온다. 이 데이터는 dictionary 자료형이다.
    • 플레이어의 모자 인덱스와 모자 데이터에 따라, 실제 적용되는 머리모양 인덱스를 계산한다.
      • 만약 모자의 hairDrawType 데이터가 hide이면 대머리(52번)를 렌더링한다.
      • 만약 모자의 hairDrawType 데이터가 true이면 머리모양을 유지한다.
      • 만약 모자의 hairDrawType 데이터가 false이면,
        • 만약 추가 헤어 데이터가 존재하면, coveredHair의 인덱스를 적용한다.
        • 추가 헤어 데이터가 존재하지 않으면, 하드코딩된 값에 따라 인덱스를 변환시킨다.
    • 플레이어의 헤어 인덱스에 따라, 추가 헤어 데이터의 key값에 인덱스가 있으면 추가 헤어 스프라이트를 불러오고, 그렇지 않다면 hairstyles.xnb를 불러온다.
    • 헤어 인덱스와 플레이어의 방향, 추가 헤어 데이터의 usesUniqueLeftSprite에 따라 헤어를 렌더링한다.
      • 헤어스타일 스프라이트시트의 각 스프라이트는 16x32 사이즈를 가진다.
    • 앞은 0픽셀 y 오프셋을, 오른쪽은 32픽셀 y 오프셋을, 뒤는 64픽셀 y 오프셋을 가진다.
      • 플레이어가 왼쪽을 바라보고, 추가 헤어 데이터가 존재하며 usesUniqueLeftSprite가 true이면,
      • 96픽셀 y 오프셋을 적용한다.
      • 만약 그렇지 않다면 32픽셀 y 오프셋을 적용한 뒤 좌우반전한다.
    • 헤어의 렌더링 위치는 하드코딩된 행동의 offset과 성별에 따라 달라진다.
      • 스탠딩 스프라이트 기준으로, x 오프셋은 0, y 오프셋은 1이다.
      • 플레이어가 남성이고, 16번 이후의 헤어를 착용할 경우, y 오프셋에 -1을 더한다.(위로 1픽셀 올린다.)
      • 플레이어가 여성이고, 0~15번 헤어를 착용할 경우, y 오프셋에 1을 더한다.(아래로 1픽셀 내린다.)
  • hats를 렌더링한다.

    • 플레이어가 뒤를 바라보고 있고, 모자의 id가 Mask 문자열을 포함하고, 모자의 hairDrawType 데이터가 hide가 아니라면,
      • 모자의 위쪽 20x11픽셀은 머리보다 뒤에, 아래쪽 20x9픽셀은 머리보다 앞에 렌더링한다.
      • 그렇지 않다면, 모든 픽셀을 머리보다 앞에 렌더링한다.
    • 플레이어의 방향에 따라, 스프라이트시트에서 모자를 렌더링한다.
      • 모자 스프라이트시트의 각 스프라이트는 20x20 사이즈를 가진다.
      • 앞, 왼쪽, 오른쪽, 뒤 순서대로 0, 20, 40, 60픽셀의 y 오프셋을 적용한다.
    • 머리색상은 플레이어의 머리색상 데이터와 머리 스프라이트를 multiply한 값으로 결정된다.
      • 참고로 이게 스타듀밸리에서 예쁜 금발을 가진 캐릭터를 만들 수 없는 이유이기도 하다.
    • 모자의 렌더링 위치는 하드코딩된 행동의 offset과 성별, 모자의 ignoreHairOffset 데이터에 따라 달라진다.
      • 스탠딩 스프라이트 기준으로, x 오프셋은 -2, y 오프셋은 -2이다.
      • 플레이어가 뒤를 보고 있으면 y 오프셋에 -1을 더한다.
      • 모자의 ignoreHairOffset 데이터가 true이면,
        • 플레이어의 렌더링되는 헤어 인덱스를 16으로 나눈 값이 3, 6, 8이면 y 오프셋에 1을 더한다.
      • 플레이어가 여성이면 y 오프셋에 1을 더한다.
  • shirts를 렌더링한다.

    • 플레이어의 방향에 따라, 스프라이트시트에서 상의를 렌더링한다.
      • 상의는 x 오프셋이 0인 uncolored 스프라이트를 먼저 렌더링하고, x 오프셋이 128인 colored 스프라이트를 나중에 렌더링한다.
      • 상의의 색상은 상의 아이템의 염색 데이터와 상의 colored 스프라이트를 multiply한 값으로 결정된다.
      • 앞, 왼쪽, 오른쪽, 뒤 순서대로 0, 8, 16, 24픽셀의 y 오프셋을 적용한다.
    • 상의의 렌더링 위치는 성별, 플레이어의 방향에 따라 달라진다.
      • 기본 x 오프셋은 4, y 오프셋은 15이다.
      • 플레이어가 뒤를 보고 있으면 y 오프셋에 -1을 더한다.
      • 플레이어가 여성이면 y 오프셋에 1을 더한다.
  • pants를 렌더링한다.

    • 플레이어의 방향에 따라, 스프라이트시트에서 하의를 렌더링한다.
      • x, y 오프셋은 스프라이트시트에서 body 스프라이트를 결정하는 방식과 동일하다.
      • 플레이어가 여성이면, x 오프셋에 96을 더한다.
    • 하의의 렌더링 위치는 body의 렌더링 위치와 동일하다.(x, y 오프셋 모두 0)

pixi.js와 mobx를 이용한 반응형 스프라이트 구현

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의 영역으로 끌고 올라올 수 있다는 점에 의의가 있다.

profile
홍익인간이 되고 싶은 꿈꾸는 방랑자

0개의 댓글