개발 조각글

Unity Refactoring - 달팽이키우기 Action 분리하기

BaekNohing 2022. 8. 13. 01:57

a cute cat snail

 

달팽이 키우기 리팩토링 목차

  1. 리팩토링 준비
  2. 구조 재설계
  3. UI Manager 설계
  4. 클래스간 관계
  5. Action 분리하기
  6. 핵심루프 설계
  7. 유닛 테스트

 

달팽이키우기 Action 분리하기 요약 :
Action Manager클래스를 통해, 버튼에 할당할 기능(Action)) 목록과 할당된 기능의 실제 구현을 분리해서 관리하기로 했다. 이로써 버튼은 ActionManager내의 action을 적절히 골라, 조합하여 사용할 수 있고, 구현부에서는 미리 정의된 인자와 출력헝태를 준수한다면. 내부 실 구현을 언제든지 자유롭게 바꿀 수 있게 된다.

 

새롭게 단장한 달팽이 키우기 ( 대부분의 기능은 전과 같다)

(구) 달팽이키우기의 버튼&액션 

달팽이키우기를 만들었던 당시에는 프로그래밍 패턴이라는게 있다는 걸 알기 이전이었기 때문에, 상당수의 분기들이 스크립트 내에 하드코딩 되어 있었고 조건을 string의 형태로 관리하거나(ex. "Play"), PlayerPerf와 같은 일종의 전역값을, 여기저기에서 참조하는 상황이었다.

이 중 UI 버튼의 경우, 퍼블릭 함수 _AnimationControll을 버튼 inspector의 On Click() 부분에다 드래그 & 드롭으로 연결해준 뒤, 바꾸려는 애니메이션의 이름을 손으로(!!) 입력해줘야 했었다. (= 이게 진짜 전체 몇천줄자리 작은 규모의 시스템이었어서 망정이지, 수기 입력에.. 인스펙터 <-> 스크립트 동시 사용에.. 휴먼 에러가 일어날 확률이 매우 높은 환경이었다..!) 

당시 상황 ↓

더보기

 

당시 inspector의 모습

↓ 이건 당시 사용했던 "달팽이의 애니메이션을 바꾸는" 함수, _AnimationControll을 손으로 OnClick에 연결해준 뒤, 인자로 받는 string name을 버튼 인스펙터 상에서 손으로 입력해 주어야 했다. (비슷한 버튼이 7개 정도 있었다.)

두번째 라인에 보이는 "d30"도 Status를 변경할 때 집어넣는 인자였는데. d는 변경할 stat이었고. subString(0, 1)로 d를 잘라서, switch문에 들어갈 case를 지정한 뒤 이후 값을 int.Prais(str.subString(1, str.Length - 1))으로 변환해서 반영했었다. 지금 살펴보면 굉장히 실수가 발생하기 좋은, 위험한 환경이지만..

왜 이렇게 설계했냐면, 나름대로 "여러개의 스테이터스를 각각의 함수로 분리하지 않으면서(=한 함수로 관리하면서)", "나중에 읽었을 때, 이 시스템을 완전히 숙지하고 있는 사람이 봤을 때, 해당 버튼이 무엇을 변화시키는지 인스펙터 상에서 알 수 있도록" 의도해사 짠 설계였다.   

public bool animationPalyChecker = false;

public void _AnimationControll(string name)
{
    if (!animationPalyChecker)
    {
        StartCoroutine(SpineAnimationCorutine(name));
        animationPalyChecker = true;
    }
}

IEnumerator SpineAnimationCorutine(string name)
{
    TouchBlock.SetActive(true);

    string tempName = CreatureSkeleton.AnimationName;
    CreatureSkeleton.AnimationState.SetAnimation(0, name, true).TimeScale = 1f;

    yield return new WaitForSeconds(2f);

    CreatureSkeleton.AnimationState.SetAnimation(0, tempName, true).TimeScale = 1f;
    animationPalyChecker = false;

    TouchBlock.SetActive(false);
}

(신) 달팽이키우기에선 어떻게 바뀌었는가? 

먼저 상수를 관리하는 Consts.cs 부분에 CreatureActionType을 enum으로 선언해서. 선언된 값만 인자로 넣어줄 수 있도록 수정했다. 또한 ActionManager 내부에 List<System.Action(CreatureActionType, int>>를 선언하고. 등록할 수 있는 함수와 꺼내서 쓸 수 있는 함수를 만들었는데. 

위 도식처럼 Action<Type, int>만 준수한다면 등록할수도, 꺼내서 쓸수 있기 때문에. 구현과 사용 두 영역은 ActionManager만 알고있다면 서로를 몰라도 괜찮도록 구성했다. ("CleanCode"에서 말했던 것 처럼 직접적인 의존성을 줄여서 캡슐화 해보기). 그리고 각 UI페이지별로 스크립트를 만들어서. 페이자가 초기화되는 그 순간에 각 버튼에 들어갈 기능들을 조합해서 넣어주었다. 

즉, 달팽이 애니메이션 상태 변경, 플레이어 스테미나 소모, 달팽이 스테이터스 증감과 같은 서로 다른 영역의 기능들을 블록처럼 AcationManager에서 꺼내, 수행해야하는 기능들을 레고처럼 조합할 수 있게 했다. (레거시 코드에서도 이와같은 방식을 사용하고 있었는데.. "게임 프로그래밍 패턴"에서 보았던 컴포넌트 패턴과 유사하고 나름 관리하기에 괜찮았던 부분이라고 생각해 계승했다.)

이렇게 Status.cs라는 한 스크립트로 관리되던 달팽이키우기는 ActionManager, CreaturManager, 각 UI 패널 스크립트. 등으로 쪼개지게 되었고 이를 통해 가급적이면 한 클래스가 한 기능을 담당하는 상황을 만들고자 했다. 


코드 결과물들  

ActionManager 내 ActionList 부분 ↓

더보기
public class ActionManager : Monobehaviour
{
	...
    
	List<System.Action<CreatureActionType, int>> creatureActionList =
    new List<System.Action<CreatureActionType, int>>();
    public void RegistCreatureAction(System.Action<CreatureActionType, int> action)
    {
        if(creatureActionList == null) 
        	creatureActionList = new List<System.Action<CreatureActionType, int>>();
        if(!creatureActionList.Contains(action))
            creatureActionList.Add(action);
    }
    public void DoCreatureAction(CreatureActionType actionType, int value = 0)
    {
        ComponentUtility.Log($"DoCreatureAction {actionType} {value}");
        foreach(var action in creatureActionList)
            action(actionType, value);
    }
    
    ...
}

CreatureManager 내 Action구현 및 등록 부분 ↓

더보기
public class CreatureManager : Monobehaviour
{
	...
    void RegistCreatureAction(){
        actionManager.RegistCreatureAction(SetCreatureActionType);
    }

    void SetCreatureActionType(CreatureActionType actionType, int value)
    {
        if(dataManager.PlayerInfo.isDead) return;

        switch(actionType)
        {
            case CreatureActionType.stand:
                StopCoroutine(SetAnimation(actionType, value));
                StartCoroutine(SetAnimation(actionType, value));
                break;
            case CreatureActionType.Play:
                StopCoroutine(SetAnimation(actionType, value));
                StartCoroutine(SetAnimation(actionType, value));
                break;
            case CreatureActionType.Clean:
                actionType = CreatureActionType.Play;
                StopCoroutine(SetAnimation(actionType, value));
                StartCoroutine(SetAnimation(actionType, value));
                break;
            case CreatureActionType.Eat:
                StopCoroutine(SetAnimation(actionType, value));
                StartCoroutine(SetAnimation(actionType, value));
                break;
            case CreatureActionType.dead:
                CreatureDead();
                break;
            case CreatureActionType.evolve:
                CreatureEvolve(value);
                break;
        }
    }
    ...
}

UI에서 버튼에 사용될 Action을 할당하는 부분 ↓

더보기
public class UIFood : UIPanels
{
	...
	void SetMeatButton(ActionManager actionManager)
    {
        float needStamina = 10f;
        float recoverHunger = 10f;


        btnMeat.SetButtonOption(() =>
        {
            return (actionManager.CheckActionCondition(ConditionCheckType.stamina, needStamina) &&
                    actionManager.CheckActionCondition(ConditionCheckType.alive, 0));
        });
        ComponentUtility.SetButtonAction(btnMeat, () =>
        {
            actionManager.DoPropAction("meat");
            actionManager.DoStatusAction(StatusType.hunger, recoverHunger);
            actionManager.DoConditionConsumeAction(ConditionCheckType.stamina, (int)needStamina);
            actionManager.DoCreatureAction(CreatureActionType.Eat, GameLoop.animationTime);
        });
    }
    ...
}