개발 조각글

Unity Refactoring - 게임의 핵심루프 설계

BaekNohing 2022. 8. 13. 21:15

a cute cat snail

 

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

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

 

Unity Refactoring - 게임의 핵심루프 설계 요약
각 스크립트의 Awake나 Start에 의존하지 않고, 게임 루프가 시작하기 전 확실하게 초기화 할 수 있도록 했고. 
ActionManager의 update함수에서 루프를 관리하되, 각 기능이 호출되는 프레임을 다르게 설정해서, 항상 의도한 순서대로 함수가 실행될 수 있도록 (민감한 함수들의 실행 순서가 뒤바뀌지 않도록) 조정했다. 

달팽이키우기는 관리 대상이 달팽이 딱 하나이기 때문에, 업데이트문을 한 클래스에서 집중적으로 관리하는게 좋을 것 같다고 생각했다.  그래서 ActionManager를 선언한 다음 유저 스테미나 관리, 달팽이 스테이터스 소모 등 매 틱마다 확인해야 하는 액션들을 해당 클래스 내 List<system.Action> tickActionList에 등록해서 Update()함수가 호출될 때 마다 등록된 함수들이 일괄적으로 실행되도록 했다.

이렇게 매 틱마다 갱신하는 함수를 묶어서 일괄적으로 처리하게 되면 장점이 한가지 더 생기는데. Consts내부에 Enum FramOrder에서 확인할 수 있듯, 함수들의 실행 순서를 조금 더 능동적으로 관리할 수 있다는 것이다. 

달팽이키우기는 프레임에 민감한 게임이 아니고, 매 프레임마다 모든 값이 반드시 다 갱신되어야 할 필요가 없었기 때문에, 60프레임으로 고정한 뒤. 각 함수 머리에 SkipFrame(FrameOrder order)을 선언하는 것으로, 각 함수들이 자기차례가 왔을때만 실행되도록 해 두었다.

그리고, 회사 프로젝트에서 "클래스가 완전히 준비되지 않았는데 준비가 끝난 클래스에서 호출해버리는 바람에 스크립트 실행이 꼬여버리는 문제"를 해결하는게 조금 까다롭다는걸 굉장히 고통스럽게 배워버렸기 때문에.. 그런 일이 아예 일어날 수 없도록  ActionManager의 Update문은 본격적인 tickActionList를 실행하기 전, 각 클래스가 보내주는 initFlag의 값을 먼저 체크함으로써, 각 클래스가 확실하게 준비되었을 때에만 실행될 수 있도록 설계해두었다.  

 

public class ActionManager : MonoBehaviour
{
	...
    public Dictionary<string, bool> initFlag = 
        new Dictionary<string, bool>{
            {nameof(UIManager), false},
            {nameof(DataManager), false},
            {nameof(CreatureManager), false},
            {nameof(ObjectManager), false},
        };
    ...
    void Update()
    {
        if(!AllClassReady)
        {
            // Wait for all class ready
            if(AllClassReady = CheckClassReady())
                Init();
        }
        else// AllClassReady == true
        {
            if(GameLoop.SkipFrame(frameOrder.refresh)) return;
            // Central Game Loop
            foreach (System.Action action in tickActionList)
                action();
            foreach (KeyCode key in keyActionDict.Keys)
                if(Input.GetKeyDown(key))
                    foreach (System.Action action in keyActionDict[key])
                        action();
        }
    }

    void Init(){
        Application.targetFrameRate = 60;
        foreach(var action in initActionList)
            action();
        ComponentUtility.Log("AllClassReady");
    }

    bool CheckClassReady(){
        foreach(var flag in initFlag.Values)
            if(!flag)
                return false;
        return true;
    }

    private void OnApplicationQuit() {
        foreach (System.Action action in quitActionList)
            action();    
    }
    ...
}
public class CreatureManager
{
	...
    void Awake()
    {
        dataManager = this.GetComponent<DataManager>();
        actionManager = this.GetComponent<ActionManager>();

        RegistInitAction();
        RegistCreatureAction();
        RegistEvolveAction();

        actionManager.initFlag[nameof(CreatureManager)] = true;
    }

    void RegistInitAction(){
        actionManager.RegistInitAction(()=>
            LoadCreature(
                dataManager.PlayerInfo.isDead,
                dataManager.PlayerInfo.creatureIndex
            )
        );
    }
    ...
}
namespace Consts
{
    public enum frameOrder {
        stat = 0,
        stamina = 5,
        evolve = 10,
        dead = 15,
        refresh
    }
    
    ...
    
    static class GameLoop
    {
        ...

        static public bool SkipFrame(frameOrder order)
        {
            // frame division is 20
            int frameCount = Time.frameCount;
            int frameRemainder = (int)(frameCount * 0.05);
            if (frameCount - frameRemainder * 20 != (int)order)
                return false;
            return true;
        }
    }
}