글 작성자: Doublsb

How We Die 회고, 개발편이다. 프로젝트 내내 다른 부분에서 고통받다가 유일하게 힐링할 수 있었던 구간이 개발이다.

그 말은 시스템을 개발했던 6월까지만 행복했고 콘텐츠 개발을 해야 했던 7월부터는 지옥에 있었다는 소리다. (...)

프로젝트 마무리즈음에 잘하는 것만 하고 살고 싶은 욕구가 늘었다가... 출시 후에 유저분들이 플레이하는 걸 보고 어쨌든 행복해짐 :P

작업 방식을 먼저 쓰고, 전체적인 개발 구조는 더 아래에 쓰는 방식으로 서술하겠다. 아래로 내려갈 수록 읽고 싶지 않은 글이 되겠구만.

 

 

Sentry를 사용하여 에러 로그 수집

재직 중에 파이어베이스 크래시리틱스를 사용했더니 안정성이 대폭 올라간 경험을 이미 해봤기 때문에, 이번 프로젝트도 적용은 필수라고 생각했다. 그런데 파이어베이스는 데스크탑 지원이 베타 상태라는 충격적인 사실을 알아버려서 Sentry를 적용하였다. 아무튼 로그만 날라오면 되니까 뭘 써도 상관없었지만.

 

데모 단계부터 적용한 덕분에 출시 때 카드 효과 버그는 안 나왔다. 근데 이제 본편만의 콘텐츠는 예상치 못한 버그가 많았기에 미친듯이 업데이트를 했다. 다행히 이제는 크리티컬한 버그는 관찰되지 않는 중. 만약 로그 수집을 적용하지 않았다면... 어... 끔찍하다.

다만 이렇게 소 잃고 외양간 고치는 방식은 플레이어도 고통이고 나도 고통이니 차기작을 한다면 얼리억세스로 개발하거나 QA 업체에 컨택을 해야 할 것 같다. ^Q^...

센트리 로그 화면. 플레이에 영향은 없지만 아직 씬 전환으로 인한 UniTask 널 레퍼 문제가 남아있다

 

 

 

테스트 툴 개발

테스트 툴 개발도 필수. 툴은 미루면 미룰수록 개발 효율이 낮아지기 때문에 빠르게 만들었다. 프로젝트 기간이 6개월이기에 최소 스펙으로 만들었지만.

로컬라이징이 잘 적용되었는지 봐야 했으므로 카드/특성 일람과 스토리 콘텐츠 테스트 화면을 만들고, 카드 효과의 작동을 확인하기 위해 전투만 가능한 테스트 화면을 만들었다.

 

 

 

 

데이터 기반 작업

하드코딩은 죄악이고 데이터는 신이다. 타 게임도 모두 하는 작업방식이지만 게임 내의 모든 콘텐츠를 데이터화했다.

BakingSheet를 사용하면서 너무 행복했는데, 컬럼 안의 컬럼을 만들 수 있었기에 컬럼명을 Param_1, Param_2로 쓰는 자동으로 이마치기가 발생하는 작업을 하지 않아도 되었기 때문이다. 데이터는 구글 스프레드시트로 작업하고 로컬 데이터로 만들어 관리했다.

 

카드 데이터
선택지 데이터

 

 

 

카드/특성 효과 구조 설계

카드나 특성 효과는 보통 여러 효과를 조합하는 방식이기 때문에 개별 효과를 모듈화하는 작업 방식이 필요했다.

전체 구조를 다 얘기하는 건 길어지기 때문에, 잘했다고 생각하는 부분만 말해보겠다.

public class ModifierAddDice : ModifierActiveBase
    {
        private CharacterSelectUtil.SingleTargetType type;
        private int addCount;

        public ModifierAddDice(ModifierData data, CharacterSelectUtil.SingleTargetType type) : base(data)
        {
            this.type = type;
            this.addCount = data.IntParam(0);
        }

        protected override async UniTask OnAction(Character owner, ActionTile tile)
        {
            var target = await CharacterSelectUtil.GetTarget(type, owner, tile);
            
            if(target == null)
                return;
            
            await AddDice(target);
        }

        protected async UniTask AddDice(Character character)
        {
            var sides = character.Modifiers.GetStatDices();

            var diceList = new List<Dice>();
            
            for (int i = 0; i < addCount; i++)
            {
                var obj = DiceDataManager.Instance.Spawn(sides);
                diceList.Add(obj);
            }
            
            character.AddDices(diceList);
        }
    }

 

이건 주사위를 추가하는 효과 모듈이다. Data의 0번 파라미터를 가져와 더할 개수로 변환한다.

OnAction()을 수행하면 해당 캐릭터에 주사위를 추가함.

    [ModifierInitializerDefine(22)]
    public class ModifierInitializer_AddDiceToCurrent : ModifierInitializer
    {
        public override ModifierBase Initialize(InGameProcessor processor, ModifierStorage storage, ActionTile tile, ActionInitializer initData)
        {
            var data = new ModifierData((int)ModifierCategory.Action, 22, initData);
            var modifier = new ModifierAddDice(data, CharacterSelectUtil.SingleTargetType.Current);

            storage.Add(modifier);
            
            return modifier;
        }
    }

 

이건 22번 효과로서 선언한 AddDiceToCurrent이다. 카드 효과를 발동한 대상에게 주사위를 추가하는 효과를 가지고 있음.

그럼 이제 관련 효과를 사용하는 카드를 만든다면 데이터 시트로 가서 넣기만 하면 됨.

 

도넛, 초코바는 22번 스킬 ID를 사용하고, 파라미터에 1이 들어가있는 것을 확인할 수 있다.

새 효과를 추가할 때에는 복잡하지 않게 관련 ModifierActive를 만들고 어트리뷰트로 ID에 엮기만 하면 되도록 만들었음.

 

만약 주사위는 더하면서 주사위 보너스를 감소시키고 싶은 경우 각각의 Modifier를 발동시키는 Modifier를 만들면 된다.

public class ModifierAddDiceAndRemoveDiceBonus : ModifierActiveBase
    {
        private ModifierAddDice addDiceModifier;
        private ModifierAddSumBonus bonusModifier;
        
        /// Int[0] = Dice Count
        /// Int[1] = Bonus
        public ModifierAddDiceAndRemoveDiceBonus(ModifierData data, ActionTile tile) : base(data)
        {
            var addDiceModifierData = new ModifierData(data.Category, data.Id, intParam: new() { data.IntParam(0) });
            var bonusDiceModifierData = new ModifierData(data.Category, data.Id, intParam: new() { -data.IntParam(1) });
            
            addDiceModifier = new ModifierAddDice(addDiceModifierData, CharacterSelectUtil.SingleTargetType.Current);
            bonusModifier = new ModifierAddSumBonus(bonusDiceModifierData, CharacterSelectUtil.SingleTargetType.Current);
        }
        
        protected override async UniTask OnAction(Character owner, ActionTile tile)
        {
            await addDiceModifier.Action(owner, tile);
            await bonusModifier.Action(owner, tile);
        }
    }

 

사실 데이터상에서 ID와 파라미터를 리스트 형태로 받고 순차적으로 실행시키도록 만드는 방법도 있는데, 재직 중에는 비슷한 구조를 짤 때 해당 방법을 사용했었다. 근데 UI 표기를 위해 '대표 파라미터'나 '대표 태그' 등을 가지고 와야 하는 문제로 골머리를 썩힌 적이 있어서 이번에는 이렇게 작업했다. 엑셀 시트 자체의 한계도 있고... 아예 데이터 툴을 만들면 해결되는 문제겠지만 오버스펙이니까.

 

 

 

Processor, Process 구조 설계

이번에는 UniTask를 적극적으로 사용해봤다. 그리고 경험으로 GameManager가 싱글톤인게 거슬렸기 때문에 이번에는 일반 객체 형태로 설계해 봄. 프로세스 객체를 큐에 넣고 순차적으로 해결한다. 이걸로 OutGameProcessor와 InGameProcessor를 만들었는데, 작동에는 문제 없었지만 코드를 붙여넣고 읽어 보니 리팩토링할 요소들이 보이는 듯. TODO가 해결 안 된것도 그렇고 ^w^...

 

그런데 메인 구조가 코루틴이 아니었기 때문에 고통을 받았던 포인트가 몇몇 있다.

에러 로그 발생 시 스택 트레이스를 추적하는 게 힘들어서 디버깅을 하는 데 어려움을 겪었고, 몇몇 비동기 메서드는 UniTask로 쓰는 게 아니라 void로 쓰는 바람에 에러 로그가 안 날아오기도 하는(...) 슬픔을 겪기도 했다.

 

플레이에 영향은 없지만 오브젝트가 죽었을 때에도 Task는 작동해서 널 레퍼런스를 발생시키는 부분이 몇몇 있었다는 것도 좀 그럼. 이 부분은 사실 Cancel을 명확히 해 주는 구조를 만들었어야 했는데 일단 다른 게 바빠서 하지 못했다.

 

public class Processor<T> where T : Process
    {
        public float ElapsedDeltaTime { protected set; get; } = 0f;
        public bool IsPause { private set; get; } = false;

        protected Queue<T> queue = new();
        
        protected List<T> nonSequential = new();
        protected List<T> reservedDeleteNonSequential = new();
        public T Current => queue.Count > 0 ? queue.Peek() : null;
        public virtual bool IsRunning => queue.Count > 0;

        public async UniTask AddNonSequential(Process p)
        {
            if (p == null)
                return;
            
            var process = p as T;

            if (process == null)
                return;
            
            process.OnEnqueue();
            nonSequential.Add(process);
            
            await UniTask.WaitUntil(() => p.State == Process.ProcessState.End);
            
            nonSequential.Remove(process);
            
        }
        
        public virtual void Add(T process)
        {
            //TODO : process pool에서 캐싱하면 new를 더 안 때리고 Add 메서드로 진입 가능하도록
            
            if(process == null)
                return;
            
            queue.Enqueue(process);
            process.OnEnqueue();
            
            if(IsPause)
                Current?.OnPause();
        }

        public virtual void Update(float dt)
        {
            if(IsPause)
                return;

            foreach (var e in nonSequential)
            {
                if (e.State == Process.ProcessState.End)
                    reservedDeleteNonSequential.Add(e);
            }

            foreach (var e in reservedDeleteNonSequential)
            {
                if(e.State == Process.ProcessState.Complete)
                    nonSequential.Remove(e);
                else
                    e.OnTryEnd(false);
            }
            
            reservedDeleteNonSequential.Clear();
            
            ElapsedDeltaTime += dt;
            
            if(Current == null)
                return;

            if(Current?.State == Process.ProcessState.End)
                Current?.OnTryEnd(false);

            if (Current?.State == Process.ProcessState.Complete)
                DeQueue(false);
            
            //Kill 이후에 다음 Process가 Delay가 되는 경우
            if(Current?.State == Process.ProcessState.Delay)
                Current?.OnDelay(dt);
            
            //Kill 이후에 다음 Process가 Head가 되는 경우
            if(Current?.State == Process.ProcessState.Wait)
                Current?.OnHead();
            
            //Work가 된 Process가 업데이트
            if(Current?.State == Process.ProcessState.Work)
                Current?.OnUpdate(dt);
            
            //TODO : Process 교체 방식의 문제점
            //새로 Head가 된 Process가 OnUpdate에 바로 End 상태가 되는 경우
            //1프레임 뒤에 OnDequeue가 발생하는 문제가 생김.
        }

        /// 현재 작동중인 프로세스 중지
        public virtual Process DeQueue(bool isForceKill)
        {
            if (Current == null)
                return null;
            
            if (Current.State == Process.ProcessState.Complete)
                return queue.Dequeue();

            if (Current.State != Process.ProcessState.End)
            {
                Console.Log("취소를 해본다~~");
                Current.OnTryEnd(isForceKill);
            }

            return null;
        }

        public virtual void Pause()
        {
            IsPause = true;
            Current?.OnPause();
        }

        public virtual void Resume()
        {
            IsPause = false;
            Current?.OnResume();
        }
        
        public virtual void Clear(bool isForceKill)
        {
            DeQueue(isForceKill);
            queue.Clear();
        }
    }

 

public abstract class Process
    {
        public enum ProcessState
        {
            Delay,
            Wait,
            Start,
            Work,
            End,
            Ending,
            Complete,
        }

        public ProcessState State { protected set; get; } = ProcessState.Delay;
        public float DelayTime;

        public void OnEnqueue()
        {
            State = ProcessState.Delay;
        }

        public void OnDelay(float dt)
        {
            DelayTime += dt;
            
            if(DelayTime >= Delay())
                State = ProcessState.Wait;
        }
        
        public void OnHead()
        {
            Console.Log("OnHead : " + this.GetType().Name);
            
            State = ProcessState.Start;
            OnStart().Forget();
        }

        public void OnTryEnd(bool isForceKill)
        {
            if(State is ProcessState.Start or ProcessState.Ending or ProcessState.Complete)
                return;
            
            if(!isForceKill && State != ProcessState.End)
                Console.LogError("End가 아닌데 어케 옴");
            
            State = ProcessState.End;
            OnEnd(isForceKill).Forget();
        }

        protected virtual float Delay() => 0.5f;

        protected virtual async UniTask OnStart()
        {
            State = ProcessState.Work;
        }

        public virtual void OnUpdate(float dt) {}

        public virtual async UniTask OnEnd(bool isForceKill = false)
        {
            State = ProcessState.Ending;
            State = ProcessState.Complete;
        }
        
        public virtual void OnPause() {}

        public virtual void OnResume() {}
    }

 

 

 

StateMachine

왜 그랬는지 돌이켜보면 잘 모르겠는데, 라이브러리를 안 쓰고 상태 머신을 스스로 짰다.

아무튼 이건 캐릭터나 주사위 상태 제어에 썼음. 너무 간단한 구조라서 그냥 넘어갈란다. 다음에는 라이브러리를 쓰는 게 안정성이 높으려나...?

public class StateMachine
    {
        public State Current { private set; get; } = null;
        public State Last { private set; get; } = null;
        
        public void Update(float dt)
        {
            Current?.OnUpdate(dt);
        }
        
        public void Next<T>(T state) where T : State
        {
            if(typeof(T) == Current?.GetType())
                return;
            
            if(Current != null && !Current.IsNextAble(state))
                return;
            
            if(Current != null && !state.IsPreviousAble(Current))
                return;
            
            Last = Current;
            OnPreExit(Last);
            Last?.OnExit(state);
            Current = state;
            
            OnPreEnter(Current);
            Current?.OnEnter(Last);
        }

        protected virtual void OnPreEnter(State nextState) {}

        protected virtual void OnPreExit(State currentState) {}
        
        public void Clear()
        {
            Last = Current;
            
            Last?.OnExit(null);
            Current = null;
        }
    }

    public class State
    {
        public virtual async UniTask OnEnter(State lastState) {}
        
        public virtual void OnUpdate(float dt) {}

        public virtual async UniTask OnExit(State nextState) {}
        
        public virtual bool IsNextAble(State nextState) => true;
        public virtual bool IsPreviousAble(State previousState) => true;
    }

 

 

 

기타

오브젝트 풀링이나 드로우 콜 등의 기초적인 최적화는 다 하긴 했는데, 데스크탑 기반이라 작업해도 티가 하나도 안 남... 아예 구형 컴퓨터를 가져오지 않는 이상에야 알 수도 없겠지. 최적화하는 맛은 없는 프로젝트였다.

 

어드레서블도 습관적으로 그냥 썼다. 익숙해서 말할 거리는 없군 (...)

 

구조 자체는 UniTask의 단점 말고는 문제를 못 느낀 프로젝트인 것 같다. 아니, 그 단점이 프로젝트의 전부다. 파훼법을 좀 찾아봐야겠음.

반응형