깃허브 주소 : https://github.com/GAFinal-Predator/Predator

게임 시연 영상 : https://youtu.be/xjCrXEDaerc?si=laKyc-V1Po2zvh5H

 

 목차

 

1. 프로젝트 개요

2. 프로토타입 게임 개발

3. 빌드머신 구축, 빌드와 테스트의 자동화

4. Yunuty 게임엔진 개선

4.1. 길찾기 라이브러리 Recast Navigation 연동

4.2. 물리 라이브러리 PhysX 연동

4.3. 컴포넌트 코루틴 구현

5. Wander 에디터

6. RTS 프레임워크 개발

7. UI

8. 성과

8.1. 게임 플레이 영상

8.2. 개발 과정 영상

8.3. 도쿄 게임쇼

9. 느낀 점


 

1. 프로젝트 개요

 게임 인재원 파이널 졸업 프로젝트는 1년의 시간을 두고 진행되는 장기 게임 개발 프로젝트였다. 모든 팀원이 서로 열심히 노력해서 개발한 결과 게임 인재원 우수 프로젝트로 지정되어 도쿄 게임쇼에 출품하는 영광을 얻을 수 있었다.

 기획된 게임은 여러 캐릭터들을 동시에 조종하는 실시간 쿼터뷰 액션 게임이었다. 게임 플레이 엔진은 내가 여러 프로젝트를 거치며 만들어 온 Yunuty 엔진을 사용하기로 했고, 그래픽스 엔진은 DirectX11 기반으로 팀원이 만들기로 했다.

팀 구성  
프로그래밍 팀장 ( 게임 엔진 )
프로그래밍 팀원 ( 맵 툴 ) A
프로그래밍 팀원 ( 그래픽스 엔진 ) B
프로그래밍 팀원 ( 콘텐츠 프로그래밍 ) C
아트 팀 3인
기획 팀 2인

 

프로젝트 발표에 쓰인 기획 프레젠테이션

 

2. 프로토타입 게임 개발

( 게임 플레이 기획의 영감을 얻었다는 두 게임, '트랜지스터'와 '드래곤 에이지 인퀴지션'. )

 

 프로젝트를 시작하기 이전에 기획자로부터 어떤 게임을 만들 생각인지 대강의 설명을 들었다. 쿼터뷰 시점의 다중 유닛 컨트롤 게임이자, 실시간 전투와 시간정지 플레이가 혼재된 액션 게임이라고 설명을 들었다. 그렇지만 내가 이해한 바가 기획자의 생각과 일치하는지가 여전히 모호했다. 앞으로 장기간 협업하게 될 테니 서로 생각하고 있는 게임의 형태를 처음부터 확실하게 공유하고 싶었다. 마침 게임의 형태가 RTS 게임과 비슷했고, 나는 RTS 게임 워크래프트 3의 맵 에디터 기능을 능숙하게 다룰 수 있었기 때문에 짧은 시간 안에 프로토타입 게임을 먼저 만들었다.

( 재미 검증을 위한 프로토타입 게임, 스킬과 전술모드, 적군 웨이브가 구현되었다. )
( 밸런싱 작업을 위한 프로토타입 게임의 수치 테이블 )

 

 프로토타입 게임의 목적은 단지 팀이 같은 비전을 공유하는 것만이 아니었다. 우리 게임만을 위한 맵 에디터와 그래픽스 엔진, RTS 코드를 완전히 처음부터 만드는 만큼, 실질적으로 구동되는 게임을 기획자가 받아보는 시점은 매우 늦어질 예정이었다. 그렇다고 기획자의 레벨 디자인과 수치 밸런싱 작업을 그때까지 마냥 미룰 수는 없었다. 이런 작업들은 병렬적으로 진행될 필요가 있었다. 기획자는 이 프로토타입을 통해 그러한 수치들을 적용하고 확인할 수 있었다. 자체 맵 에디터를 워크래프트3 에디터와 유사하게 만들 계획이었기 때문에, 기획자가 에디터의 활용에 미리 익숙해지는 효과도 기대할 수 있었다.

( 프로토타입의 지형 배치, 이 배치가 그대로 게임 레벨디자인에 적용되었다. )

 

 

 

 

 

 

3. 빌드머신 구축, 빌드와 테스트의 자동화

 프로토타입을 전달해 기획자의 작업이 병렬적으로 수행될 수 있게 되었으니, 이제 우리는 본격적인 프로그램 개발에만 집중하면 됐다. 프로젝트가 긴 시간 동안 진행될 예정이었기 때문에, 코드 작성에 앞서 개발 프로세스에 대해 신중히 고민하는 시간을 가졌다. 장기 프로젝트에서는 잘못된 커밋이 쌓여 버그가 발생할 경우, 문제의 원인을 찾는 것이 마치 볏짚 속에서 바늘을 찾는 것처럼 어려워질 수 있었다. 이를 방지하기 위해 테스트 주도 개발 방식을 도입했다. 우리는 주요한 기능을 구현할때마다 그 기능의 정상작동을 검증하는 단위 테스트 코드도 짝을 지어 만들기로 했다. 그리고 빌드머신을 세팅해 커밋이 들어올 때마다 테스트들을 진행시켜  모두 통과해야 빌드를 성공으로 표시하게 만들었다. 테스트 자동화 시스템 덕분에 잘못된 커밋이 들어오면 즉각적으로 해당 커밋이 어떤 문제를 일으키는지 특정할 수 있었다.

 

링크 : 게임개발 프로세스에 테스트 자동화 환경을 구축한 이야기

 

 

 

 

 

 

4. Yunuty 게임엔진의 개선

 RTS 게임을 만들기 위해 게임 엔진에서 지원해야 할 기능들이 있었다. 하나는 길찾기 기능, 하나는 3차원 충돌 기능이었다. 자체 개발한 길찾기 기능과 2D 충돌 기능이 준비되어 있었지만, 최적화와 예외처리에서 미심쩍은 부분들이 많았다. 때문에 안정성이 확보된 오픈소스 라이브러리인 RecastNavigation과 PhysX를 게임엔진과 연동시켜 사용했다.

 

 

4.1. 길찾기 라이브러리 Recast Navigation 연동

좌 : Recast Navigation Demo 프로그램의 모습, 우 : Recast Navigation이 게임엔진에 연동되어 동작하는 모습

 

 라이브러리의 데모 프로젝트의 코드를 참고해 게임엔진에서 NavigationField와 NavigationAgent라는 각각의 클래스로 래핑했다. 게임 엔진 사용자가 최대한 간단하게 기능을 사용할 수 있도록 인터페이스를 뚫어주고 싶었다. NavigationField는 버텍스 좌표들의 리스트만 받으면 이동가능한 지형을 생성할 수 있게 만들었고, NavigationAgent는 이동속도 설정, 충돌 크기 설정, 이동 등의 기능만 사용할 수 있게 만들었다.

 

링크 : 자체 게임 엔진에 RecastNavigation 이식하기

길찾기 기능을 래핑하기 위한 인터페이스 설계

 

4.2. 물리 라이브러리 PhysX 연동

 PhysX 라이브러리의 기능을 연동하고 래핑했다. 물체의 충돌범위 판정 속성과 강체로서의 속성은 따로 구분해 Collider와 Rigidbody의 두 클래스로 만들었다.

Collider와 Rigidbody의 설계

PhysX 라이브러리를 Yunuty 엔진에서 래핑한 코드

더보기

- Collider 클래스 헤더

#pragma once
#include <Windows.h>
#include <vector>
#include <unordered_set>
#include "Graphic.h"
#include "Vector2.h"
#include "Vector3.h"
#include "Rect.h"

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

using namespace std;
namespace yunutyEngine
{
    namespace physics
    {
        class RigidBody;
        class YUNUTY_API Collider : virtual public Component
        {
        public:
            class Impl;
            Collider(Impl* impl);
            virtual ~Collider();
            //bool IsTrigger();
            //void SetAsTrigger(bool isTrigger);
            bool IsUsingCCD();
            // Continuous Collision Detection 기능을 활성화하면 한 프레임에서 다음 프레임까지의 충돌을 연속적으로 체크합니다.
            // CCD 기능이 활성화되면 Bullet through paper 현상이 발생하지 않습니다.
            void EnableCCD(bool enable);
            // 콜라이더의 크기가 0이면 터짐, 콜라이더 크기가 매우 작을때 적용되는 최소한의 스케일
        protected:
            virtual void Start()override;
            virtual void Update()override;
            virtual void OnTransformUpdate() override;
            // 피직스 객체의 월드스케일이 달라졌을 때 이를 어떻게 반영할지 결정합니다.
            virtual void ApplyScale(const Vector3d& worldScale) = 0;
            virtual void OnEnable()override;
            virtual void OnDisable()override;
            RigidBody* rigidBody;

            Impl* impl;
        private:
            bool WasPxActorInitialized();
            Vector3d cachedScale;
            friend RigidBody;
#ifdef _DEBUG
            Vector3d firstLocation;
            Quaternion firstRotation;
#endif
        };
    }
}

 

-Rigidbody 클래스 헤더

#pragma once
#include <Windows.h>
#include <vector>
#include <unordered_set>
#include "Graphic.h"
#include "Vector2.h"
#include "Vector3.h"
#include "Rect.h"
#include "YunutyForceType.h"
#include "YunutyPhysicsMaterialInfo.h"

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

// RigidBody는 콜라이더에 강체 속성을 부여하고 싶을때 사용됩니다. 콜라이더 없이는 RigidBody의 코드가 제대로 동작할 수 없습니다.
using namespace std;
namespace yunutyEngine
{
    namespace physics
    {
        class Collider;
        class YUNUTY_API RigidBody : public Component
        {
        public:
            RigidBody();
            virtual ~RigidBody();
            bool GetIsStatic();
            void SetAsStatic(bool isStatic);
            void SetAsKinematic(bool isKinematic);
            bool GetIsKinematic();

            //void SetMaterialInfo();

            void LockTranslation(bool x, bool y, bool z);
            void LockRotation(bool x, bool y, bool z);
            void SetMass(float mass);
            void AddForce(const Vector3f& forceVector, ForceType forceType);
            void AddTorque(const Vector3f& forceVector, ForceType torqueType);
        protected:
            virtual void Start() override;
            virtual void OnEnable()override;
            virtual void OnDisable()override;
        private:
            std::vector<std::function<void()>> onStartCallbacks;
            Collider* attachedCollider;
            bool isStatic{ false };
            friend Collider;
        };
    }
}

 

이식된 물리 기능, 애석하게도 게임에서 물리 시뮬레이션 기능이 쓰이는 일은 없었다.

4.3. 컴포넌트 코루틴 구현

 유니티에는 StartCoroutine과 같은 함수를 통해 컴포넌트에서 코루틴 객체를 붙여 비동기적 동작을 실행하는 기능이 있다. 내 엔진에도 같은 기능이 있으면 좋을것 같아 유니티와 유사하게 만들어 보았다. 유니티는 코루틴 함수에서 YieldInstruction 객체를 반환할 수 있게 만들어 코루틴이 재활성화되는 시점을 손쉽게 조절할 수 있도록 만들었다. 나도 유사한 클래스를 만들어 '이 코루틴은 yield 후 n초 후 다시 동작'과 같은 편의성을 제공했다.

 

관련 코드

더보기

- YunutyCoroutine.h

#pragma once

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

#include <vector>
#include <functional>

namespace yunutyEngine
{
    class Component;
    class YunutyCycle;
    namespace coroutine
    {
        class YieldInstruction;
        struct YUNUTY_API Coroutine
        {
            struct Coroutine_Handler;
            struct promise_type {
                YieldInstruction* yield = nullptr;
                std::vector<std::function<void()>> destroyCallBack;
                Coroutine get_return_object()
                {
                    return Coroutine(std::make_shared<Coroutine_Handler>(std::coroutine_handle<promise_type>::from_promise(*this)));
                }
                std::suspend_always initial_suspend() { return std::suspend_always{}; }
                std::suspend_always final_suspend() noexcept { return std::suspend_always{}; }
                void return_void() {}
                /*void return_value(YieldInstruction& yield) { this->yield = &yield; }
                void return_value(YieldInstruction&& yield) { this->yield = &yield; }*/
                std::suspend_always yield_value(std::suspend_always) { return {}; }
                std::suspend_always yield_value(YieldInstruction& yield) { this->yield = &yield; return {}; }
                std::suspend_always yield_value(YieldInstruction&& yield) { this->yield = &yield; return {}; }
                void unhandled_exception() 
                {
                    std::rethrow_exception(std::current_exception());
                }
            };
            explicit Coroutine(const std::shared_ptr<Coroutine_Handler>& h) : handle(h) {}
            ~Coroutine()
            {
                if (handle)
                {
                    for (auto each : handle->promise().destroyCallBack)
                    {
                        each();
                    }
                }
            }
            Coroutine(Coroutine const&) = delete;
            Coroutine(Coroutine&& other) noexcept
            {
                deathWish = other.deathWish;
                handle = other.handle;
                other.handle.reset();
            };
            void PushDestroyCallBack(const std::function<void()>& callBack)
            {
                handle->promise().destroyCallBack.push_back(callBack);
            }
            Coroutine& operator=(Coroutine const&) = delete;
            void resume() { handle->resume(); }
            YieldInstruction* GetLastYield() { return handle->promise().yield; };
            bool Done() { return handle->done(); }

            class Coroutine_Handler
            {
                friend struct Coroutine;
            public:
                Coroutine_Handler(const std::coroutine_handle<promise_type>& handle)
                    : primitive_handle(handle)
                {

                }

                ~Coroutine_Handler()
                {
                    if (primitive_handle)
                    {
                        primitive_handle.destroy();
                    }
                }

                auto& promise()
                {
                    return primitive_handle.promise();
                }
                
                void resume()
                {
                    primitive_handle.resume();
                }

                bool done()
                {
                    return primitive_handle.done();
                }

            private:
                std::coroutine_handle<promise_type> primitive_handle;
            };

            std::shared_ptr<Coroutine_Handler> handle;
        private:
            bool deathWish = false;
            friend yunutyEngine::Component;
            friend yunutyEngine::YunutyCycle;
        };
    }
}

( 코루틴 클래스의 정의 )

 

- YunutyYieldInstruction.h

#pragma once

namespace yunutyEngine
{
    namespace coroutine
    {
        class YieldInstruction
        {
        public:
            virtual bool ShouldResume() const = 0;
            virtual void Update() = 0;
        };
    }
}

( 코루틴 함수에서 반환할수 있는 객체 YieldInstruction의 정의 )

- YunutyWaitForSeconds.h

#pragma once
#include "YunutyYieldInstruction.h"

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

namespace yunutyEngine
{
    namespace coroutine
    {
        class YUNUTY_API WaitForSeconds :public YieldInstruction
        {
        private:
            float elapsed{ 0 };
            float seconds{ 0 };
            bool isRealTime{ false };
        public:
            WaitForSeconds(float seconds, bool isRealTime = false);
            virtual void Update() override;
            virtual bool ShouldResume() const override;
            float GetExceededTime() const;
        };
    }
}

( 코루틴이 n초동안 쉬었다가 다시 동작을 재개하게 만드는 YieldInstruction의 한 형태인 WaitForSeconds )

 

- YunutyCycle.cpp

...
...
for (unsigned int i = 0; i < updateTargetComponentsSize; i++)
{
    if (!updateTargetComponents[i]->GetActive() || !updateTargetComponents[i]->GetGameObject()->GetActive())
        continue;
    UpdateComponent(updateTargetComponents[i]);
    for (auto coroutine : updateTargetComponents[i]->coroutines)
    {
        if (coroutine->Done() || coroutine->deathWish)
        {
            continue;
        }
        if (auto yield = coroutine->GetLastYield(); yield)
        {
            yield->Update();
            if (yield->ShouldResume())
            {
                coroutine->handle->promise().yield = nullptr;
                coroutine->resume();
            }
        }
        else
        {
            coroutine->resume();
        }
    }
    if (!updateTargetComponents[i]->coroutines.empty())
    {
        std::erase_if(updateTargetComponents[i]->coroutines, [](std::shared_ptr<yunutyEngine::coroutine::Coroutine> coroutine) {return coroutine->Done() || coroutine->deathWish; });

        if (!updateTargetComponents[i]->coroutines.empty())
        {
            updateTargetComponents[i]->GetGameObject()->HandleComponentUpdateState(updateTargetComponents[i]);
        }
    }
}
...
...

( 컴포넌트 객체를 Update할 때 Coroutine도 같이 업데이트하는 부분 )

 

 

 

 

 

 

5. RTS 프레임워크 개발

InWanderLand Contents.drawio
0.01MB
콘텐츠 코드의 전체 설계

 

 

 

 유닛, 유닛 컨트롤러, 투사체, 스킬, 상태이상과 같은 RTS 프레임워크 코드를 설계하고 구현했다. 이 중 가장 핵심이 되는 클래스는 유닛이었다. 이동, 공격, 스킬 시전과 같은 유닛의 상태는 행동 트리로 관리했다.

InWanderLand Unit Behaviour Tree.drawio
0.02MB
유닛의 상태를 관리하기 위한 행동 트리의 설계도

 

행동 트리와 관련된 코드

더보기

BehaviourTree.h

#include <vector>
#include <functional>
#include <unordered_set>
#include <unordered_map>

class BehaviourTree
{
public:
    struct Node
    {
    private:
        int nodeKey;
        std::unordered_map<int, Node> children;
        std::vector<Node*> childrenInOrder;
    public:
        // 재심청구 플래그, 이 플래그가 true이면 노드의 onExit 주기를 실행한 후 다시 조건을 확인한다.
        int GetNodeKey() const { return nodeKey; };
        std::function<void()> onEnter = []() {};
        std::function<void()> onExit = []() {};
        std::function<void()> onUpdate = []() {};
        std::function<bool()> enteringCondtion = []() {return false; };
        Node& operator[](int key);
        friend BehaviourTree;
    };
    const std::vector<const Node*>& GetActiveNodes()const { return activeOnCurrentUpdate; };
    void Update(int reassessCount = 0);
    static constexpr int maxReassessCount = 3;
    Node& operator[](int key);
    bool reAssessFlag = false;
private:
    std::vector<const Node*> activeOnLastUpdate;
    std::vector<const Node*> activeOnCurrentUpdate;
    std::unordered_map<int, Node> rootChildren;
    std::vector<Node*> rootChildrenInOrder;
};
BehaviourTree::Node& BehaviourTree::Node::operator[](int key)
{
    if (!children.contains(key))
    {
        children[key] = Node{};
        children[key].nodeKey = key;
        childrenInOrder.push_back(&children[key]);
    }
    return children[key];
}
void BehaviourTree::Update(int reassessCount)
{
    if (reassessCount > maxReassessCount)
        return;
    reAssessFlag = false;
    activeOnCurrentUpdate.clear();
    const auto* nodeChildren = &rootChildrenInOrder;
    while (nodeChildren && !nodeChildren->empty())
    {
        auto nodeChildrenBefore = nodeChildren;
        for (const auto& node : *nodeChildren)
        {
            if (node->enteringCondtion())
            {
                activeOnCurrentUpdate.push_back(node);
                nodeChildren = &node->childrenInOrder;
                break;
            }
        }
        assert(nodeChildrenBefore != nodeChildren, "behaviour node can't reach the leaf node.");
    }
    // 먼저 Exit를 순차적으로 부르고 다음 Enter들을 부른다.
    for (int i = activeOnLastUpdate.size() - 1; i >= 0 && reassessCount == 0; i--)
    {
        if (activeOnCurrentUpdate.size() - 1 < i || activeOnLastUpdate[i] != activeOnCurrentUpdate[i])
        {
            activeOnLastUpdate[i]->onExit();
        }
    }
    // Exit를 부른 상태에서 재심청구가 들어왔다면, 다시 Update문을 실행한다.
    if (reAssessFlag)
    {
        Update(reassessCount + 1);
        return;
    }
    for (int i = 0; i < activeOnCurrentUpdate.size(); i++)
    {
        if (((int)activeOnLastUpdate.size() - 1 < i) || (activeOnLastUpdate[i] != activeOnCurrentUpdate[i]))
        {
            activeOnCurrentUpdate[i]->onEnter();
        }
        activeOnCurrentUpdate[i]->onUpdate();
    }
    activeOnLastUpdate = activeOnCurrentUpdate;
}
BehaviourTree::Node& BehaviourTree::operator[](int key)
{
    if (!rootChildren.contains(key))
    {
        rootChildren[key] = Node{};
        rootChildren[key].nodeKey = key;
        rootChildrenInOrder.push_back(&rootChildren[key]);
    }
    return rootChildren[key];
}

 

 

Unit.cpp

...
...
unitBehaviourTree[UnitBehaviourTree::Hold][UnitBehaviourTree::Stop].enteringCondtion = [this]()
    {
        return true;
    };
unitBehaviourTree[UnitBehaviourTree::Hold][UnitBehaviourTree::Stop].onEnter = [this]()
    {
        OnStateEngage<UnitBehaviourTree::Stop>();
    };
unitBehaviourTree[UnitBehaviourTree::Hold][UnitBehaviourTree::Stop].onExit = [this]()
    {
        OnStateExit<UnitBehaviourTree::Stop>();
    };
unitBehaviourTree[UnitBehaviourTree::Move].enteringCondtion = [this]()
    {
        return CanProcessOrder<UnitOrderType::Move>();
    };
unitBehaviourTree[UnitBehaviourTree::Move].onEnter = [this]()
    {
        currentOrderType = pendingOrderType;
        pendingOrderType = UnitOrderType::None;
        OnStateEngage<UnitBehaviourTree::Move>();
    };
unitBehaviourTree[UnitBehaviourTree::Move].onExit = [this]()
    {
        OnStateExit<UnitBehaviourTree::Move>();
        if (pendingOrderType == UnitOrderType::None)
        {
            OrderHold();
            unitBehaviourTree.reAssessFlag = true;
        }
    };
unitBehaviourTree[UnitBehaviourTree::Move].onUpdate = [this]()
    {
        OnStateUpdate<UnitBehaviourTree::Move>();
    };
 ...
 ...

( 유닛의 초기화 코드 중 유닛이 가진 행동 트리의 각 노드의 진입조건, 진입시 실행할 함수, 해당 노드가 활성화되어있는 동안 실행할 업데이트 동작 등 노드의 속성을 지정하는 코드 )

( 빨간색 구는 유닛의 공격 사거리 안에 속한 적을 탐지하기 위한 충돌체, 회색 구는 포착 거리 내의 적을 탐지하기 위한 충돌체다. )

 

 유닛의 적 추적 범위, 사거리와 같이 일정 범위 안의 유닛을 탐색해야 하는 기능은 구형의 충돌체를 유닛의 위치에 붙이는 방식으로 구현했다.

 

 유닛을 탐색하는 데에 쓰이는 UnitAcquisitionCollider 클래스의 코드

더보기

UnitAcquistionCollider.h

#pragma once
#include "YunutyEngine.h"
#include "UnitAcquisitionCollider.h"

class Unit;
class UnitAcquisitionCollider : virtual public Component
{
public:
    // nullable
    std::weak_ptr<Unit> owner;
    int teamIndex{ 0 };
    // 콜라이더에 닿은 모든 유닛과 owner 기준 적, 아군 유닛들의 목록
    const std::unordered_set<Unit*>& GetUnits() { return units; }
    const std::unordered_set<Unit*>& GetEnemies() { return enemies; }
    const std::unordered_set<Unit*>& GetFriends() { return friends; }
    bool includeDeadUnits = false;
    bool includeInvulnerableUnits = false;
protected:
    virtual void Update() override;
    int GetTeamIndex();

    // 유닛이면 일단 집어넣는 리스트
    std::unordered_set<Unit*> unitsWhatSoEver;

    std::unordered_set<Unit*> units;
    std::unordered_set<Unit*> enemies;
    std::unordered_set<Unit*> friends;

    GameObject* debugMesh = nullptr;
private:
    bool ShouldContain(Unit* unit);
    virtual void OnTriggerEnter(physics::Collider* other) override;
    virtual void OnTriggerExit(physics::Collider* other) override;
};

 

UnitAcquistionCollider.cpp

#include "UnitAcquisitionCollider.h"
#include "InWanderLand.h"

bool UnitAcquisitionCollider::ShouldContain(Unit* unit)
{
    return (includeDeadUnits || unit->IsAlive()) && (includeInvulnerableUnits || !unit->IsInvulenerable());
}

void UnitAcquisitionCollider::OnTriggerEnter(physics::Collider* other)
{
    if (auto collider = other->GetGameObject()->GetComponent<UnitCollider>(); collider)
    {
        auto unit = collider->owner.lock().get();
        unitsWhatSoEver.insert(unit);
        bool includeDeadUnits = false;
        bool includeInvulnerableUnits = false;
        if (ShouldContain(unit))
        {
            units.insert(unit);
            int teamIndex = GetTeamIndex();
            int otherTeamIndex = unit->GetTeamIndex();
            if (teamIndex != otherTeamIndex)
            {
                enemies.insert(unit);
            }
            else
            {
                friends.insert(unit);
            }
        }
    }
}

void UnitAcquisitionCollider::OnTriggerExit(physics::Collider* other)
{
    if (!other) return;
    if (auto collider = other->GetGameObject()->GetComponent<UnitCollider>(); collider)
    {
        auto unit = collider->owner.lock().get();
        unitsWhatSoEver.erase(unit);
        units.erase(unit);
        enemies.erase(unit);
        friends.erase(unit);
    }
}

void UnitAcquisitionCollider::Update()
{
    units.clear();
    friends.clear();
    enemies.clear();
    std::copy_if(unitsWhatSoEver.begin(), unitsWhatSoEver.end(), std::inserter(units, units.end()), [this](Unit* const unit) { return ShouldContain(unit); });
    std::copy_if(units.begin(), units.end(), std::inserter(friends, friends.end()), [this](Unit* const unit) {return unit->GetTeamIndex() == GetTeamIndex(); });
    std::copy_if(units.begin(), units.end(), std::inserter(enemies, enemies.end()), [this](Unit* const unit) {return unit->GetTeamIndex() != GetTeamIndex(); });
}

int UnitAcquisitionCollider::GetTeamIndex()
{
    if (owner.expired())
        return teamIndex;
    else
        return owner.lock()->GetTeamIndex();
}

 

Unit 클래스의 멤버 변수로 쓰이며 각각 유닛의 공격범위, 유닛의 적 탐색 범위 역할을 수행하는 UnitAcquisitionCollider

...
...
    // 공격범위와 적 포착범위
    std::weak_ptr<UnitAcquisitionSphereCollider> attackRange;
    std::weak_ptr<UnitAcquisitionSphereCollider> acquisitionRange;
    std::weak_ptr<yunutyEngine::graphics::Animator> animatorComponent;
 ...
 ...

 

( 지면에서 문어발을 소환해 유닛을 중심으로 끌어모으는 스킬 )

 

 유닛의 스킬은 코루틴으로 구현했다.

 

코드 보기

더보기

지면에서 문어발을 소환해 유닛을 중심으로 끌어모으는 스킬의 코드

 

UrsulaParalysisSkill.cpp

coroutine::Coroutine UrsulaParalysisSkill::operator()()
{
    colliderEffectRatio = 3.0f * 0.5f;
    auto blockFollowingNavigation = owner.lock()->referenceBlockFollowingNavAgent.Acquire();
    auto blockAnimLoop = owner.lock()->referenceBlockAnimLoop.Acquire();
    auto disableNavAgent = owner.lock()->referenceDisableNavAgent.Acquire();
    animator = owner.lock()->GetAnimator();
    paralysisAnim = wanderResources::GetAnimation(owner.lock()->GetFBXName(), UnitAnimType::Skill2);

    owner.lock()->SetDefaultAnimation(UnitAnimType::None);
    owner.lock()->PlayAnimation(UnitAnimType::Skill2);

    effectColliderCoroutine = owner.lock()->StartCoroutine(SpawningFieldEffect(dynamic_pointer_cast<UrsulaParalysisSkill>(selfWeakPtr.lock())));
    effectColliderCoroutine.lock()->PushDestroyCallBack([this]()
        {
            tentacleAnimator->GetGI().SetPlaySpeed(1);
            waveAnimator->GetGI().SetPlaySpeed(1);
            waveVFXAnimator.lock()->SetSpeed(1);

            FBXPool::Instance().Return(tentacleObject);
            FBXPool::Instance().Return(waveObject);
            UnitAcquisitionSphereColliderPool::Instance().Return(damageCollider);
            UnitAcquisitionSphereColliderPool::Instance().Return(knockBackCollider);
        });

    while (!animator.lock()->IsDone())
    {
        co_await std::suspend_always{};
    }

    disableNavAgent.reset();
    blockFollowingNavigation.reset();
    owner.lock()->Relocate(owner.lock()->GetTransform()->GetWorldPosition());
    if (owner.lock()->GetPendingOrderType() == UnitOrderType::None)
        owner.lock()->OrderAttackMove();
    co_return;
}

 

 Unit 클래스에서 스킬 코루틴 객체를 실행하고 coroutineSkill 변수에 집어넣는 코드

 

Unit.cpp

template<>
void Unit::OnStateEngage<UnitBehaviourTree::SkillCasting>()
{
    assert(pendingSkill.get() != nullptr);
    SetDesiredRotation(pendingSkill.get()->targetPos - GetTransform()->GetWorldPosition());
    onGoingSkill = std::move(pendingSkill);
    coroutineSkill = StartCoroutine(onGoingSkill.get()->operator()());
    onSkillActivation(onGoingSkill);
}

 

 스킬 코루틴이 현재 진행중이라면 유닛 행동 트리의 SkillOnGoing 노드가 활성화된다.

 

Unit.cpp

...
...
  unitBehaviourTree[UnitBehaviourTree::Skill][UnitBehaviourTree::SkillOnGoing].enteringCondtion = [this]()
        {
            return !coroutineSkill.expired();
        };
...
...

유닛과 투사체같이 재활용할 수 있는 객체들은 오브젝트 풀에서 관리되도록 만들었다.

 

오브젝트 풀 관련 코드 보기

더보기

GameObjectPool.h

#pragma once
#include "Component.h"
#include "GameObject.h"
#include "Scene.h"
#include "SingletonClass.h"
#include <functional>
#include <unordered_set>
#include <deque>
#include <vector>

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

using namespace std;
using namespace yunutyEngine;
namespace yunutyEngine
{
    // 오브젝트가 필요하면 생성하고, 생성된 오브젝트의 용도가 다 끝나면 폐기하는 대신 비활성화만 시켜놨다가,
    // 다시 오브젝트에 대한 요청이 들어오면 재활성화 시키는 오브젝트 풀 객체입니다. 
    template<typename RepresenstativeComponent>
    class GameObjectPool
    {
        static_assert(std::is_base_of<Component, RepresenstativeComponent>::value, "only derived classes from component are allowed");
    public:
        virtual GameObject* GameObjectInitializer() { return Scene::getCurrentScene()->AddGameObject(); };
        // 빌려줄 오브젝트가 단 하나도 없을 경우, 활성화된 씬에서 게임 오브젝트를 생성한 뒤 RepresentativeComponent를 붙여줍니다.
        // 이 작업 이후 추가적으로 실행할 초기화 함수를 정의합니다.
        virtual void ObjectInitializer(std::weak_ptr<RepresenstativeComponent> comp) = 0;
        // 오브젝트를 하나 빌렸을 때 실행될 함수를 정의합니다. 굳이 정의 안해도 됩니다.
        virtual void OnBorrow(std::weak_ptr<RepresenstativeComponent> comp) {};
        // 게임 오브젝트 풀에 저장된 게임 오브젝트를 활성화합니다.
        std::weak_ptr<RepresenstativeComponent> Borrow();
        // 게임 오브젝트 풀에서 관리하는 게임 오브젝트를 되돌려 줍니다.
        void Return(std::weak_ptr<RepresenstativeComponent>);
        int poolObjectsSize() { return poolObjects.size(); };
        int expendableObjectsSize() { return expendableObjects.size(); };
        void Clear();

    protected:
        const deque<std::weak_ptr<RepresenstativeComponent>>& GetPoolObjects() {
            return poolObjects;
        }
    protected:
        std::deque<std::weak_ptr<RepresenstativeComponent>> poolObjects;
        std::deque<std::weak_ptr<RepresenstativeComponent>> expendableObjects;
        std::unordered_set<RepresenstativeComponent*> expendableObjectSet;
    };

    template<typename RepresenstativeComponent>
    std::weak_ptr<RepresenstativeComponent> GameObjectPool<RepresenstativeComponent>::Borrow()
    {
        if (expendableObjects.empty())
        {
            auto gameObject = GameObjectInitializer();
            auto component = gameObject->AddComponentAsWeakPtr<RepresenstativeComponent>();
            ObjectInitializer(component);
            poolObjects.push_back(component);
            expendableObjects.push_back(component);
        }
        auto target = expendableObjects.front();
        expendableObjects.pop_front();
        expendableObjectSet.erase(target.lock().get());
        OnBorrow(target);
        target.lock()->GetGameObject()->SetSelfActive(true);
        return target;
    }

    template<typename RepresenstativeComponent>
    void GameObjectPool<RepresenstativeComponent>::Return(std::weak_ptr<RepresenstativeComponent> obj)
    {
        if (expendableObjectSet.contains(obj.lock().get()))
        {
            return;
        }
        expendableObjects.push_back(obj);
        expendableObjectSet.insert(obj.lock().get());
        obj.lock()->GetGameObject()->SetSelfActive(false);
    }
    template<typename RepresenstativeComponent>
    inline void GameObjectPool<RepresenstativeComponent>::Clear()
    {
        for (auto each : poolObjects)
        {
            Scene::getCurrentScene()->DestroyGameObject(each.lock()->GetGameObject());
        }
        poolObjects.clear();
        expendableObjects.clear();
        expendableObjectSet.clear();
    }
}

 

UnitPool.h

#pragma once
#include "YunutyEngine.h"

namespace application
{
    namespace editor
    {
        class Unit_TemplateData;
        class UnitData;
    }
}
class Unit;

class UnitPool : public SingletonClass<UnitPool>
{
public:
    /// Editor 에서 Map 이 바뀔 때, 초기화하는 함수입니다.
    void Reset();
    std::weak_ptr<Unit> Borrow(application::editor::UnitData* data);
    std::weak_ptr<Unit> Borrow(application::editor::Unit_TemplateData* td, const Vector3d& position, float rotation);
    std::weak_ptr<Unit> Borrow(application::editor::Unit_TemplateData* td, const Vector3d& position, const Quaternion& rotation);
    void Return(std::weak_ptr<Unit>);
private:
    std::weak_ptr<Unit> Borrow(application::editor::Unit_TemplateData* td);
    class PoolByTemplate : public GameObjectPool<Unit>
    {
    public:
        application::editor::Unit_TemplateData* templateData{ nullptr };
        virtual void ObjectInitializer(std::weak_ptr<Unit> unit) override;
        virtual void OnBorrow(std::weak_ptr<Unit> unit) override;
    };
    std::unordered_map<const application::editor::Unit_TemplateData*, std::shared_ptr<PoolByTemplate>> poolsByTemplate;
};

 

ProjectilePool.h

#pragma once
#include "YunutyEngine.h"
#include "Projectile.h"
#include "ProjectileType.h"
#include "InWanderLand.h"
#include <DirectXMath.h>

class Projectile;
struct ProjectileType;
struct ProjectileHoming;
// ProjectilePool은 fbx 이름별로 여러가지 풀들을 갖고 있다.
// ProjectilePool은 풀 집합체와 같다고 할 수 있다.
//template <typename T>
class ProjectilePool : public SingletonClass<ProjectilePool>
{
public:
    std::weak_ptr<Projectile> Borrow(std::weak_ptr<Unit> owner, std::weak_ptr<Unit> opponent);
    void Return(std::weak_ptr<Projectile>);
private:
    class PoolByMesh : public GameObjectPool<Projectile>
    {
    public:
        string fbxname;
        virtual void ObjectInitializer(std::weak_ptr<Projectile> projectile) override;
    };
    std::unordered_map<string, std::shared_ptr<PoolByMesh>> poolsByFBX;
    std::unordered_map<Projectile*, std::weak_ptr<PoolByMesh>> poolsByProjectile;
};

 

 

 

 

 

 

6. Wander 맵 에디터

 게임에 쓰이는 맵 에디터는 워크래프트 3의 맵 에디터와 유사하게 개발했다.

( 통행 가능한 지형 마킹, 좌 : 워크래프트3 에디터, 우 : Wander 맵 에디터 )

 

( 밸런스 수치 변경 )
( 장식물 배치 )
( 유닛 배치 )
( 트리거 볼륨 역할을 하는 지역 배치 )


 워크래프트3 에디터에서 특히나 유용하게 참고한 기능은 블록 코딩 기반 스크립팅 기능이었다. 이를 통해 레벨 디자이너가 게임의 특수 이벤트와 컷신을 쉽게 만들 수 있었다.

워크래프트 3 에디터의 블록 코딩 스크립트로 구현한 컷신

 

( Wander 에디터의 블록 코딩 스크립트로 구현한 컷신 )

 

 또 우리 게임에 필요한 기능으로 유니티에서 배치된 장식물들의 트랜스폼 정보와 라이트맵 정보를 임포트하는 기능, 특정 지역에 진입하면 투명하게 처리할 장식물들을 지정하는 기능, 적군 웨이브를 배치하는 기능을 구현했다.

 [ WanderEditor가 Import Unreal Data를 통해 장식물의 배치와 라이트맵 정보를 들이는 모습 ]

( 에디터에서 지역별로 비활성화시킬 장식물들을 지정해두면 인게임에서 주인공 일행이 해당 지역에 진입할 때 지정된 장식물들이 투명처리된다. )
( 에디터에서 웨이브 유닛들을 시간별로 배치해두면 인게임 웨이브가 진행되면서 유닛들이 순차로 등장한다. )

 

 

 

 

 

 

7. UI

( UI는 유니티에서 배치된 UI 객체들의 정보를 임포트하는 방식으로 구현했다. )

 

 유니티에서 배치된 UI 정보들을 게임에서 임포트할 수 있도록 만들었다. 또한 UI 객체에 속성을 부여할수 있게 만들어 UI 창이 등장하거나 사라질 때 다양한 연출을 넣을 수 있도록 했다. UI 객체를 특정할 수 있어야 하는 경우, Enum으로 ID를 부여했다.

 

UI 익스포트 관련 코드 보기

더보기

JsonUIData.h

#pragma once
#include "Storable.h"
#include "JsonUIFloatType.h"

struct JsonUIData
{
    // string imageName;
    std::string uiName;
    std::array<float, JsonUIFloatType::NUM> floats;
    int uiIndex;
    // 부모가 없다면 부모를 1920 * 1080 크기의 스크린 스페이스로 가정하게 된다.
    int parentUIIndex = -1;
    std::string imagePath;
    std::string koreanImagePath;
    std::string englishImagePath;
    std::string japaneseImagePath;
    int imagePriority;
    // 만약 플래그에 openingButton이 있다면 버튼을 눌렀을 때 활성화시킬 UI 창을 의미한다.
    std::vector<int> openTargets;
    // 만약 플래그에 diablingButton이 있다면 버튼을 눌렀을 때 비활성화시킬 UI 창을 의미한다.
    std::vector<int> closeTargets;
    std::vector<int> disablePropagationTargets;
    std::vector<int> enablePropagationTargets;
    std::vector<int> hoverEnableTargets;
    std::vector<float> color;
    std::vector<float> pivot;
    std::vector<float> anchor;
    std::vector<float> anchoredPosition;
    std::vector<float> enableOffset;
    std::vector<float> disableOffset;
    bool popUpX, popUpY, popUpZ;
    bool popDownX, popDownY, popDownZ;
    // 숫자를 표현할 때, 각 자릿수별로 숫자 폰트 이미지를 갈아치울 UI 객체들을 의미합니다.
    std::vector<int> numberDigits;
    // 숫자를 표현할 때 사용할 숫자 폰트 이미지 세트를 의미합니다.
    int numberFontSet;
    // 0~9까지의 숫자 폰트 이미지를 매핑합니다.
    std::vector<int> numberFontSetImages;
    // 숫자를 표현할 때, 올림처리하면 true, 내림처리하면 false입니다.
    bool numberCeil;
    // 0을 표시할지 여부입니다.
    bool numberShowZero;
    std::string soundOnClick;
    std::string soundOnHover;
    std::string soundOnEnable;
    std::string soundOnDisable;
    int enableCurveType;
    int disableCurveType;
    // 업그레이드 버튼의 경우, 활성화하기 위해 필요한 다른 버튼의 인덱스를 의미합니다.
    int dependentUpgrade{ -1 };
    vector<float> linearClipOnEnableStart;
    vector<float> linearClipOnEnableDir;
    int linearClipOnEnableCurveType;
    vector<float> linearClipOnDisableStart;
    vector<float> linearClipOnDisableDir;
    int linearClipOnDisableCurveType;
    vector<float> colorTintOnEnableStart;
    vector<float> colorTintOnEnableEnd;
    int colorTintOnEnableCurveType;
    vector<float> colorTintOnDisableStart;
    vector<float> colorTintOnDisableEnd;
    int colorTintOnDisableCurveType;
    // 전체 셀의 갯수
    int barCells_CellNumber;
    // 임의로 사용하게 될 사용자 플래그
    bool disableOnStartEdtior;
    bool disableOnStartExe;
    string musicPlayOnEnable_musicClip;
    string musicPlayOnDisable_musicClip;
    std::vector<int> exclusiveEnableGroup;
    string animatedSpriteFolderPath;
    bool animatedSpriteIsRepeat;
    bool duplicate_poolable;
    string videoPath1;
    string videoPath2;
    bool videoUnscaledDeltaTime;
    // 숫자를 가운데 정렬할지의 여부입니다.
    bool centerAlign;
    bool reAlignOnDisable;

    int customFlags;
    int customFlags2;
    // UI의 고유한 EnumID
    int enumID;
    // 사운드 매핑을 위한 ID
    int soundEnumID;
    FROM_JSON(JsonUIData);
};

(Json 형식으로 저장되는 UI 객체 하나의 정보)

 

UIExportFlag.h

enum class UIExportFlag
{
    None = 0,
    // 마우스가 올라갔을 때, 마우스가 클릭했을 때 등의 상호작용을 받습니다.
    IsButton = 1 << 0,
    // 특정 UI들을 닫는 용도로 쓰입니다.
    CloseButton = 1 << 1,
    // 특정 UI들을 여는 용도로 쓰입니다.
    OpeningButton = 1 << 2,
    // 비활성화시 그레이 스케일 적용된 이미지를 출력합니다.
    GrayScaleDisable = 1 << 4,
    // 활성화될때 팝업 효과를 줍니다.
    IsPoppingUp = 1 << 5,
    // 비활성화될때 사이즈를 줄입니다.
    IsPoppingDown = 1 << 6,
    // 버튼 위에 마우스 포인터가 올라왔을 때 자식 오브젝트들을 보여줍니다.
    IsIncludingTooltips = 1 << 7,
    // 게임을 시작할때 비활성화된 채로 시작합니다.
    DisableOnStart = 1 << 8,
    // 버튼이 토글 동작을 할때 사용됩니다.
    IsToggle = 1 << 9,
    // UI 이미지가 활성화될때 트랜슬레이션 오프셋 애니메이션을 재생합니다.
    IsTranslatingOnEnable = 1 << 10,
    // UI 이미지가 비활성화될때 트랜슬레이션 오프셋 애니메이션을 재생합니다.
    IsTranslatingOnDisable = 1 << 11,
    // 체력, 마나와 같은 값을 70 / 100과 같은 형태로 표시하는 텍스트입니다.
    // Adjuster에 들어가는 실수 값을 분자로 사용하며, 최대값이 갱신되면 분모도 갱신됩니다.
    //IsGuageText = 1 << 12,
    // 0~1 사이의 값으로 UI 이미지의 세로 크기를 조절할 수 있습니다.
    CanAdjustHeight = 1 << 13,
    // 0~1 사이의 값으로 UI 이미지의 가로 크기를 조절할 수 있습니다.
    CanAdjustWidth = 1 << 14,
    // 0~1 사이의 값으로 UI 이미지의 Radial Fill 각을 조절할 수 있습니다.
    CanAdjustRadialFill = 1 << 15,
    // 원본 이미지가 그저 사각형 흰색 이미지인지 나타냅니다.
    IsBlankImage = 1 << 16,
    // 이 이미지 아래에 깔린 버튼은 클릭이 가능합니다.
    NoOverlaying = 1 << 17,
    // 이미지가 커졌다가 작아지는 애니메이션을 반복적으로 재생합니다.
    IsPulsing = 1 << 18,
    ///////////////////////////
    // 여러 자리의 숫자를 표현합니다. 기본적으로 내림으로 연산이 되나, 0~1 사이의 값을 올림처리할지, 내림처리할지는 속성값에 따라 결정됩니다.
    IsNumber = 1 << 19,
    // 클릭할 때 효과음을 재생합니다.
    PlaySoundOnClick = 1 << 20,
    // 마우스가 올라갔을 때 효과음을 재생합니다.
    PlaySoundOnHover = 1 << 21,
    // UI 객체가 활성화될때 효과음을 재생합니다.
    PlaySoundOnEnable = 1 << 22,
    // UI 객체가 비활성화될때 효과음을 재생합니다.
    PlaySoundOnDisable = 1 << 23,
    // 자식 게임 객체들의 위치를 인덱스별로 저장해뒀다가, 활성화된 자식 객체들을 인덱스별로 저장된 위치에 자식 인덱스 순서대로 배치합니다.
    PriorityLayout = 1 << 24,
    // 0~9까지의 숫자 폰트를 의미하는 폰트 이미지 세트를 자식으로 가집니다. 이 이미지 세트는 IsNumber 속성을 가진 UI객체가 폰트 이미지를 바꾸는 데에 사용됩니다.
    IsDigitFont = 1 << 25,
    // 스킬 업그레이드에 쓰이는 버튼입니다. 선행 업그레이드를 가질 수 있습니다.
    IsSkillUpgrade = 1 << 26,
    // UI의 크기가 화면의 해상도에 따라 달라집니다.
    // 1920, 1080 해상도에서 만들어진 UI 이미지를 1280, 720 해상도에서 사용할 때, 이미지의 크기를 0.6667배로 조정합니다.
    ScaledByResolution = 1 << 27,
    // UI가 활성화될때 시간을 멈춥니다.
    TimeStopOnEnable = 1 << 28,
    // UI가 비활성화될때 시간정지를 해제합니다.
    TimeContinueOnDisable = 1 << 29,
    // UI가 활성화될때 투명도 애니메이션을 재생합니다.
    ColorTintOnEnable = 1 << 30,
    // UI가 비활성화될때 투명도 애니메이션을 재생합니다.
    ColorTintOnDisable = 1 << 31,

};
enum class UIExportFlag2
{
    None = 0,
    LinearClipOnEnable = 1 << 0,
    LinearClipOnDisable = 1 << 1,
    Duplicatable = 1 << 2,
    // 체력바, 마나 바 등 연속적인 데이터의 수치를 추정하기 위해 게이지의 일정단위마다 셀을 끊어 표시하고 싶을때 사용됩니다.
    IsBarCells = 1 << 3,
    // 0일때 하나도 클립 안함, 1일때 완전히 클립함
    AdjustLinearClip = 1 << 4,
    PlayMusicOnEnable = 1 << 5,
    PlayMusicOnDisable = 1 << 6,
    PauseMusicOnEnable = 1 << 7,
    UnPauseMusicOnDisable = 1 << 8,
    MultiplyMusicVolumeOnEnableDisable = 1 << 9,
    ExclusiveEnable = 1 << 10,
    DisableAfterEnable = 1 << 11,
    Dialogue_Manual = 1 << 12,
    Dialogue_Timed = 1 << 13,
    RedundantEnable = 1 << 14,
    AnimatedSprite = 1 << 15,
    ScriptUI = 1 << 16,
    EnumSound = 1 << 17,
    Rotating = 1 << 18,
    StartGameButton = 1 << 19,
    ReturnToTitleButton = 1 << 20,
    Video = 1 << 21,
    PropagateEnable = 1 << 22,
    PropagateDisable = 1 << 23,
    CapsuleClip = 1 << 24,
    DisableOnlyOnImport = 1 << 25,
    MultiLanguage = 1 << 26,
};

( UI 객체의 속성을 나타내는 플래그 )

 

UIEnumID

#pragma once
#include "PodStructs.h"

enum class UIEnumID
{
    // 아무것도 아님.
    None = 0,
    // 로빈, 우르술라, 헨젤의 초상화, 초상화 위의 이름 태그, 초상화를 가리는 적색 부상 오버레이, 체력바, 체력을 표시하는 텍스트 UI
    CharInfo_Robin,
    CharInfo_Ursula,
    CharInfo_Hansel,
    CharInfo_Robin_Left,
    CharInfo_Ursula_Left,
    CharInfo_Hansel_Left,
    CharInfo_Portrait,
    CharInfo_PortraitBloodOverlay,
    CharInfo_NameTag,
    CharInfo_HP_Fill,
    CharInfo_HP_Number_Current,
    CharInfo_HP_Number_Max,
    CharInfo_Buff_Bleeding,
    CharInfo_Buff_Blinding,
    CharInfo_Buff_Paralysis,
    CharInfo_Buff_KnockBack,
    CharInfo_Buff_Taunted,
    CharInfo_Buff_UrsulaSelf,
    CharInfo_Buff_HanselBuff,
    CharInfo_Buff_HanselDebuff,
    CharInfo_Skill_Use_Q,
    CharInfo_Skill_Use_Q_Overlay,
    CharInfo_Skill_Use_Q_Cooltime_Number,
    CharInfo_Skill_Use_W,
    CharInfo_Skill_Use_W_Overlay,
    CharInfo_Skill_Use_W_Cooltime_Number,
    // 전술모드 토글버튼
    Toggle_TacticMode,
    // 전술모드 토글버튼 흑백 오버레이
    Toggle_TacticMode_Overlay,
    Toggle_TacticMode_Cooltime_Number,
    // 화면 하단 마나 게이지
    ManaBar1,
    // 전술 모드 마나 게이지
    ManaBar2,
    // 화면 하단 마나 게이지 중 사용 대상이 되는 마나 오버레이
    ManaBarSpendOverlay1,
    // 전술 모드 마나 게이지 중 사용 대상이 되는 마나 오버레이
    ManaBarSpendOverlay2,
    // 현재 마나량을 나타내는 텍스트
    Mana_Text_CurrentMP,
    Mana_Text_CurrentMP_Tactic,
    // 최대 마나량을 나타내는 텍스트
    Mana_Text_MaxMP,
    Mana_Text_MaxMP_Tactic,
    // 사운드가 켜진 상태에서 표시할 UI버튼
    Sound_On,
    // 사운드가 꺼진 상태에서 표시할 UI버튼
    Sound_Off,
    // 음악이 켜진 상태에서 표시할 UI버튼
    Music_On,
    // 음악이 꺼진 상태에서 표시할 UI버튼
    Music_Off,
    // 인게임에서 사용되는 캐릭터 상태창, 전술모드 진입 버튼을 포함하는 하단 레이아웃
    Ingame_Bottom_Layout,
    // 인게임에서 사용되는 메뉴버튼
    Ingame_MenuButton,
    // 콤보 횟수를 표시함
    Ingame_Combo_Number,
    // "Combo"라는 문자를 표시함
    Ingame_Combo_Text,
    Ingame_Combo_DescriptionTitleImg,
    // 어떤 콤보를 달성해야 하는지 설명하는 텍스트, 몇 콤보를 달성해야 하는지에 대한 표시, 콤보 달성을 표시하는 체크박스의 V자 문양
    Ingame_Combo_Description1,
    Ingame_Combo_DescriptionImageUnfinished1,
    Ingame_Combo_DescriptionImageFinished1,
    Ingame_Combo_TargetNumUnfinished1,
    Ingame_Combo_TargetNumFinished1,
    Ingame_Combo_Check1,
    Ingame_Combo_Description2,
    Ingame_Combo_DescriptionImageUnFinished2,
    Ingame_Combo_DescriptionImageFinished2,
    Ingame_Combo_TargetNumUnfinished2,
    Ingame_Combo_TargetNumFinished2,
    Ingame_Combo_Check2,
    Ingame_Combo_Description3,
    Ingame_Combo_DescriptionImageUnFinished3,
    Ingame_Combo_DescriptionImageFinished3,
    Ingame_Combo_TargetNumUnfinished3,
    Ingame_Combo_TargetNumFinished3,
    Ingame_Combo_Check3,
    Ingame_Vinetting,
    BlackMask_Alpha,
    BlackMask_RightToLeft,
    BlackMask_TopToBottom,
    BlackMask_LeftToRight,
    BlackMask_BottomToTop,
    // 게임을 시작할 때, 메인화면으로 돌아갈 때에 사용되는 블랙마스크, 단순 연출용이 아니라 실제 게임로드, 종료를 위한 기능이 있다.
    BlackMask_GameLoad,
    BlackMask_GameEnd,
    LetterBox_Top,
    LetterBox_Bottom,
    // 인게임 하단에서 클릭할 시 스킬트리 메뉴를 여는 메뉴버튼
    InGame_SkiltreeMenu_Active,
    InGame_SkiltreeMenu_InActive,
    SkillPoint_Number,
    SkillUpgradeButtonRobin00,
    SkillUpgradeButtonRobin11,
    SkillUpgradeButtonRobin12,
    SkillUpgradeButtonRobin21,
    SkillUpgradeButtonRobin22,
    SkillUpgradeButtonUrsula00,
    SkillUpgradeButtonUrsula11,
    SkillUpgradeButtonUrsula12,
    SkillUpgradeButtonUrsula21,
    SkillUpgradeButtonUrsula22,
    SkillUpgradeButtonHansel00,
    SkillUpgradeButtonHansel11,
    SkillUpgradeButtonHansel12,
    SkillUpgradeButtonHansel21,
    SkillUpgradeButtonHansel22,
    SkillUpgradeButton_UpgradedImage,
    SkillUpgradeButton_Upgradable,
    SkillUpgradeButton_InUpgradableImage,
    PopUpMessage_NotEnoughSP,
    PopUpMessage_RequirementNotMet,
    PopUpMessage_PermissionForUpgrade,
    PopUpMessage_PermissionForUpgradeProceedButton,
    PopUpMessage_WarningForRestart,
    PopUpMessage_WarningForRestart_ProceedButton,
    StatusBar_Elite,
    StatusBar_MeleeEnemy,
    StatusBar_Hero_Robin,
    StatusBar_Hero_Ursula,
    StatusBar_Hero_Hansel,
    StatusBar_Boss,
    StatusBar_LeftDoor,
    StatusBar_RightDoor,
    StatusBar_Boss_Tactic,
    StatusBar_LeftDoor_Tactic,
    StatusBar_RightDoor_Tactic,
    StatusBar_HP_Number_Current,
    StatusBar_HP_Number_Max,
    StatusBar_HP_Cells,
    StatusBar_HP_Fill,
    StatusBar_SelectionName,
    TitleRoot,
    Quit_Proceed,
    MouseCursor,
    MouseCursor_Free,
    MouseCursor_Skill,
    MouseCursor_AttackMove,
    MouseCursor_OnButton,
    MouseCursor_OnEnemy,
    MouseCursor_OnAlly,
    MoveTargetFeedbackAnimSprites,
    AttackMoveTargetFeedbackAnimSprites,
    ErrorPopup_NoMana,
    ErrorPopup_Cooltime,
    ErrorPopup_CantLand,
    ErrorPopup_TacticQueueFull,
    VictoryPage,
    DefeatPage,
    TacticModeIngameUI,
    TacticModeRevertButton_Active,
    TacticModeRevertButton_InActive,
    TacticModeCommandIcon1,
    TacticModeCommandIcon2,
    TacticModeCommandIcon3,
    TacticModeCommandIcon4,
    TacticModeCommandIcon5,
    TacticModeCommandIcon6,
    TacticModeCommandIcon7,
    TacticModeCommandIcon8,
    TacticModeCommandIcon9,
    TacticModeCommandIcon10,
    TacticModeCommandIcon11,
    TacticModeCommandIcon12,
    BeaconOutside_Robin,
    BeaconOutside_Ursula,
    BeaconOutside_Hansel,
    BeaconOutside_Arrow,
    // 일반 잡졸들에 적용되는 데미지 표시기
    DamageIndicator_Default,
    DamageIndicator_Critical,
    DamageIndicator_Default_RedFont,
    DamageIndicator_Critical_RedFont,
    DamageIndicator_Default_BlueFont,
    DamageIndicator_Critical_BlueFont,
    DamageIndicator_Default_Cyan,
    DamageIndicator_Critical_Cyan,
    DamageIndicator_Default_BlackWhiteFont,
    DamageIndicator_Critical_BlackWhiteFont,
    DamageIndicator_Missed,
    DamageIndicator_Number,
    // 보스전때에만 출력되는 UI들
    BossUI_Default,
    BossUI_Tactic,
    CheckPointReached,
    LoadCheckPointButton1,
    LoadCheckPointButton2,
    InGameMenu,
    Localization,
};

( 특정 UI들의 고유한 ID를 나타내는 Enum )

 

UIYutility.cs, 유니티에서 UI 데이터를 내보내는데 쓰이는 클래스

using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEditor;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.IO;
using UnityEngine.Windows.Speech;
using Newtonsoft.Json.Serialization;
using UnityEngine.UI;
using UnityEngine.Assertions;
using JetBrains.Annotations;
using System;
using System.Linq;

public class UIYutility : MonoBehaviour
{
    [Serializable]
    public class UIDataList : List<UIExportJsonData>
    {
        public List<UIExportJsonData> dataList = new List<UIExportJsonData>();
    }
    [MenuItem("Yutility/Export ui data")]
    private static void ExportUIData()
    {
        string path = EditorUtility.SaveFilePanel("Save file as...", "", "new iwui data", "iwui");
        if (path == "")
        {
            return;
        }
        Dictionary<GameObject, int> uiIDDictanary = new Dictionary<GameObject, int>();
        HashSet<string> foundManualDialogues = new HashSet<string>();
        HashSet<string> foundTimedDialogues = new HashSet<string>();
        HashSet<string> foundScriptUI = new HashSet<string>();
        HashSet<SoundEnumComp.SoundEnumID> foundSounds = new HashSet<SoundEnumComp.SoundEnumID>();
        HashSet<UIEnumIDComp.UIEnumID> foundUIIds = new HashSet<UIEnumIDComp.UIEnumID>();
        UIDataList uilist = new UIDataList();
        //List<UIExportJsonData> uIExportJsonDatas = new List<UIExportJsonData>();
        bool failedAlready = false;
        string jsonString = "";
        List<GameObject> roots = new List<GameObject>();
        List<GameObject> taggedObjects = new List<GameObject>();
        UnityEngine.SceneManagement.Scene currentScene = SceneManager.GetActiveScene();
        currentScene.GetRootGameObjects(roots);
        foreach (var each in roots)
        {
            DFS(each, "UIExportTarget", taggedObjects, uiIDDictanary);
        }
        foreach (var each in taggedObjects)
        {
            try
            {
                each.transform.localScale = Vector3.one;
                UIExportJsonData data = new UIExportJsonData();
                data.floats = new float[(int)JsonUIFloatType.NUM];
                data.uiName = each.name;
                data.uiIndex = uiIDDictanary[each];
                if (each.transform.parent && each.transform.parent.tag == "UIExportTarget")
                {
                    data.parentUIIndex = uiIDDictanary[each.transform.parent.gameObject];
                }
                var imageComp = each.GetComponent<UnityEngine.UI.Image>();
                if (imageComp)
                {
                    data.imagePath = AssetDatabase.GetAssetPath(imageComp.sprite);
                    data.imagePath = data.imagePath.Replace("Assets/Resources/", "");
                    if (data.imagePath == "")
                    {
                        data.customFlags |= (int)UIExportFlag.IsBlankImage;
                    }
                    data.color = new float[] { imageComp.color.r, imageComp.color.g, imageComp.color.b, imageComp.color.a };
                }
                var rectTransform = each.GetComponent<RectTransform>();
                Assert.IsTrue(rectTransform.anchorMin == rectTransform.anchorMax);
                data.pivot = new float[] { rectTransform.pivot.x, rectTransform.pivot.y };
                data.anchor = new float[] { rectTransform.anchorMax.x, rectTransform.anchorMax.y };
                data.anchoredPosition = new float[] { rectTransform.anchoredPosition.x, rectTransform.anchoredPosition.y };
                data.floats[(int)JsonUIFloatType.rotation] = rectTransform.localRotation.eulerAngles.z;
                data.floats[(int)JsonUIFloatType.width] = rectTransform.sizeDelta.x;
                data.floats[(int)JsonUIFloatType.height] = rectTransform.sizeDelta.y;
                // 임의로 사용하게 될 사용자 플래그


                if (each.GetComponent<AnchoredPosMustBeZero>())
                {
                    data.anchoredPosition[0] = 0;
                    data.anchoredPosition[1] = 0;
                }
                if (each.GetComponent<UIDoesPopup>())
                {
                    var comp = each.GetComponent<UIDoesPopup>();
                    data.customFlags |= (int)UIExportFlag.IsPoppingUp;
                    data.floats[(int)JsonUIFloatType.popUpDuration] = comp.popUpDuration;
                    data.floats[(int)JsonUIFloatType.popUpFrom] = comp.popUpFrom;
                    data.floats[(int)JsonUIFloatType.popUpTo] = comp.popUpTo;
                    data.popUpX = comp.popUpX;
                    data.popUpY = comp.popUpY;
                    data.popUpZ = comp.popUpZ;
                }
                if (each.GetComponent<UIDoesPopDown>())
                {
                    var comp = each.GetComponent<UIDoesPopDown>();
                    data.customFlags |= (int)UIExportFlag.IsPoppingDown;
                    data.floats[(int)JsonUIFloatType.popDownDuration] = comp.popDownDuration;
                    data.floats[(int)JsonUIFloatType.popDownFrom] = comp.popDownFrom;
                    data.floats[(int)JsonUIFloatType.popDownTo] = comp.popDownTo;
                    data.popDownX = comp.popDownX;
                    data.popDownY = comp.popDownY;
                    data.popDownZ = comp.popDownZ;
                }
                if (each.GetComponent<UIIncludingTooltips>())
                {
                    var comp = each.GetComponent<UIIncludingTooltips>();
                    data.customFlags |= (int)UIExportFlag.IsButton;
                    data.customFlags |= (int)UIExportFlag.IsIncludingTooltips;
                    data.hoverEnableTargets = comp.tooltips.Select(each => uiIDDictanary[each]).ToArray();
                }
                if (each.GetComponent<UIIsOpeningButton>())
                {
                    var comp = each.GetComponent<UIIsOpeningButton>();
                    data.customFlags |= (int)UIExportFlag.IsButton;
                    data.customFlags |= (int)UIExportFlag.OpeningButton;
                    if (each.GetComponent<UIIsOpeningButton>().canToggle)
                    {
                        data.customFlags |= (int)UIExportFlag.IsToggle;
                    }
                    if (comp.targets != null)
                    {
                        foreach (var eachOpenTarget in comp.targets)
                        {
                            if (eachOpenTarget == null)
                            {
                                Debug.LogError("null target at " + comp.gameObject.name, comp.gameObject);
                            }
                        }
                        data.openTargets = comp.targets.Where(each => each != null).Select(each => uiIDDictanary[each]).ToArray();
                    }
                }
                if (each.GetComponent<UICloseButton>())
                {
                    var comp = each.GetComponent<UICloseButton>();
                    data.customFlags |= (int)UIExportFlag.IsButton;
                    data.customFlags |= (int)UIExportFlag.CloseButton;
                    if (comp.targets != null)
                    {
                        foreach (var eachCloseTarget in comp.targets)
                        {
                            if (eachCloseTarget == null)
                            {
                                Debug.LogError("null target at " + comp.gameObject.name, comp.gameObject);
                            }
                        }
                        data.closeTargets = comp.targets.Select(each => uiIDDictanary[each]).ToArray();
                    }
                }
                if (each.GetComponent<PropagateDisable>())
                {
                    var comp = each.GetComponent<PropagateDisable>();
                    data.customFlags2 |= (int)UIExportFlag2.PropagateDisable;
                    if (comp.targets != null)
                    {
                        data.disablePropagationTargets = comp.targets.Select(each => uiIDDictanary[each]).ToArray();
                    }
                }
                if (each.GetComponent<PropagateEnable>())
                {
                    var comp = each.GetComponent<PropagateEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.PropagateEnable;
                    if (comp.targets != null)
                    {
                        data.enablePropagationTargets = comp.targets.Select(each => uiIDDictanary[each]).ToArray();
                    }
                }
                if (each.GetComponent<UITranslatingOnEnable>())
                {
                    var comp = each.GetComponent<UITranslatingOnEnable>();
                    data.customFlags |= (int)UIExportFlag.IsTranslatingOnEnable;
                    data.floats[(int)JsonUIFloatType.enableDelay] = comp.delayTime;
                    data.floats[(int)JsonUIFloatType.enableDuration] = comp.transitioningDuration;
                    data.enableOffset = new float[] { comp.offset.x, comp.offset.y };
                    data.enableCurveType = (int)comp.curveType;
                }
                if (each.GetComponent<UITranslatingOnDisable>())
                {
                    var comp = each.GetComponent<UITranslatingOnDisable>();
                    data.customFlags |= (int)UIExportFlag.IsTranslatingOnDisable;
                    data.floats[(int)JsonUIFloatType.disableDelay] = comp.delayTime;
                    data.floats[(int)JsonUIFloatType.disableDuration] = comp.transitioningDuration;
                    data.disableOffset = new float[] { comp.offset.x, comp.offset.y };
                    data.disableCurveType = (int)comp.curveType;
                }
                if (each.GetComponent<CanAdjustHeight>())
                {
                    data.customFlags |= (int)UIExportFlag.CanAdjustHeight;
                    data.floats[(int)JsonUIFloatType.adjustingRate] = each.GetComponent<CanAdjustHeight>().adjustingRate;
                }
                if (each.GetComponent<CanAdjustWidth>())
                {
                    data.customFlags |= (int)UIExportFlag.CanAdjustWidth;
                    data.floats[(int)JsonUIFloatType.adjustingRate] = each.GetComponent<CanAdjustWidth>().adjustingRate;
                }
                if (each.GetComponent<CanAdjustRadialFill>())
                {
                    data.customFlags |= (int)UIExportFlag.CanAdjustRadialFill;
                    data.floats[(int)JsonUIFloatType.adjustingRate] = each.GetComponent<CanAdjustRadialFill>().adjustingRate;
                }
                if (each.GetComponent<DisableOnStart>())
                {
                    data.customFlags |= (int)UIExportFlag.DisableOnStart;
                    data.disableOnStartEdtior = each.GetComponent<DisableOnStart>().disableOnStartEditor;
                    data.disableOnStartExe = each.GetComponent<DisableOnStart>().disableOnStartExe;
                    if (each.GetComponent<DisableOnStart>().disableOnlyOnImport)
                    {
                        data.customFlags2 |= (int)UIExportFlag2.DisableOnlyOnImport;
                    }
                }
                if (each.GetComponent<UINoOverlaying>())
                {
                    data.customFlags |= (int)UIExportFlag.NoOverlaying;
                }
                if (each.GetComponent<CapsuleClip>())
                {
                    data.customFlags2 |= (int)UIExportFlag2.CapsuleClip;
                }
                if (each.GetComponent<IsPulsing>())
                {
                    var comp = each.GetComponent<IsPulsing>();
                    data.customFlags |= (int)UIExportFlag.IsPulsing;
                    data.floats[(int)JsonUIFloatType.pulsingMax] = comp.pulsingSizeMax;
                    data.floats[(int)JsonUIFloatType.pulsingMin] = comp.pulsingSizeMin;
                    data.floats[(int)JsonUIFloatType.pulsingPeriod] = comp.pulsingPeriod;
                }
                if (each.GetComponent<PlaySoundOnClick>())
                {
                    data.customFlags |= (int)UIExportFlag.PlaySoundOnClick;
                    data.soundOnClick = AssetDatabase.GetAssetPath(each.GetComponent<PlaySoundOnClick>().sound);
                    data.soundOnClick = data.soundOnClick.Replace("Assets/Resources/", "");
                }
                if (each.GetComponent<PlaySoundOnHover>())
                {
                    data.customFlags |= (int)UIExportFlag.PlaySoundOnHover;
                    data.soundOnHover = AssetDatabase.GetAssetPath(each.GetComponent<PlaySoundOnHover>().sound);
                    data.soundOnHover = data.soundOnHover.Replace("Assets/Resources/", "");
                }
                if (each.GetComponent<PlaySoundOnEnable>())
                {
                    data.customFlags |= (int)UIExportFlag.PlaySoundOnEnable;
                    data.soundOnEnable = AssetDatabase.GetAssetPath(each.GetComponent<PlaySoundOnEnable>().sound);
                    data.soundOnEnable = data.soundOnEnable.Replace("Assets/Resources/", "");
                }
                if (each.GetComponent<PlaySoundOnDisable>())
                {
                    data.customFlags |= (int)UIExportFlag.PlaySoundOnDisable;
                    data.soundOnDisable = AssetDatabase.GetAssetPath(each.GetComponent<PlaySoundOnDisable>().sound);
                    data.soundOnDisable = data.soundOnDisable.Replace("Assets/Resources/", "");
                }
                if (each.GetComponent<IsNumber>())
                {
                    var comp = each.GetComponent<IsNumber>();
                    data.customFlags |= (int)UIExportFlag.IsNumber;
                    data.numberCeil = comp.ceil;
                    data.numberShowZero = comp.showZero;
                    try
                    {
                        data.numberDigits = comp.digits.Select(each => uiIDDictanary[each.gameObject]).ToArray();
                    }
                    catch (Exception e)
                    {
                        Debug.LogError(e.Message, each.gameObject);
                        throw e;
                    }
                    data.numberFontSet = uiIDDictanary[comp.font.gameObject];
                }
                if (each.GetComponent<PriorityLayout>())
                {
                    var comp = each.GetComponent<PriorityLayout>();
                    data.customFlags |= (int)UIExportFlag.PriorityLayout;
                    data.floats[(int)JsonUIFloatType.layoutNormalizingTime] = comp.normalizingTime;
                    data.centerAlign = comp.centerAlign;
                    data.reAlignOnDisable = comp.reAlignOnDisable;
                }
                if (each.GetComponent<SkillUpgradeButton>())
                {
                    var comp = each.GetComponent<SkillUpgradeButton>();
                    data.customFlags |= (int)UIExportFlag.IsSkillUpgrade;
                    if (comp.dependentButton)
                    {
                        data.dependentUpgrade = uiIDDictanary[comp.dependentButton.gameObject];
                    }
                    else
                    {
                        data.dependentUpgrade = -1;
                    }
                }
                if (each.GetComponent<DigitFont>())
                {
                    data.customFlags |= (int)UIExportFlag.IsDigitFont;
                    data.numberFontSetImages = each.GetComponentsInChildren<Image>().Select(each => uiIDDictanary[each.gameObject]).ToArray();
                }
                if (each.GetComponent<ImagePriority>())
                {
                    var comp = each.GetComponent<ImagePriority>();
                    data.imagePriority = comp.priority;
                }
                if (each.GetComponent<TimeStopOnEnable>())
                {
                    var comp = each.GetComponent<TimeStopOnEnable>();
                    data.customFlags |= (int)UIExportFlag.TimeStopOnEnable;
                    data.floats[(int)JsonUIFloatType.timeStoppingDuration] = comp.timeStoppingDuration;
                }
                if (each.GetComponent<TimeContinueOnDisable>())
                {
                    data.customFlags |= (int)UIExportFlag.TimeContinueOnDisable;
                }
                if (each.GetComponent<ColorTintOnEnable>())
                {
                    var comp = each.GetComponent<ColorTintOnEnable>();
                    data.customFlags |= (int)UIExportFlag.ColorTintOnEnable;
                    data.colorTintOnEnableStart = new float[] { comp.startColor.r, comp.startColor.g, comp.startColor.b, comp.startColor.a };
                    data.colorTintOnEnableEnd = new float[] { comp.endColor.r, comp.endColor.g, comp.endColor.b, comp.endColor.a };
                    data.colorTintOnEnableCurveType = (int)comp.curveType;
                    data.floats[(int)JsonUIFloatType.colorTintOnEnableDuration] = comp.duration;
                }
                if (each.GetComponent<ColorTintOnDisable>())
                {
                    var comp = each.GetComponent<ColorTintOnDisable>();
                    data.customFlags |= (int)UIExportFlag.ColorTintOnDisable;
                    data.colorTintOnDisableStart = new float[] { comp.startColor.r, comp.startColor.g, comp.startColor.b, comp.startColor.a };
                    data.colorTintOnDisableEnd = new float[] { comp.endColor.r, comp.endColor.g, comp.endColor.b, comp.endColor.a };
                    data.colorTintOnDisableCurveType = (int)comp.curveType;
                    data.floats[(int)JsonUIFloatType.colorTintOnDisableDuration] = comp.duration;
                }
                if (each.GetComponent<LinearClipOnEnable>())
                {
                    var comp = each.GetComponent<LinearClipOnEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.LinearClipOnEnable;
                    data.linearClipOnEnableStart = new float[] { comp.startPos.x, comp.startPos.y };
                    data.linearClipOnEnableDir = new float[] { comp.clipDirection.x, comp.clipDirection.y };
                    data.floats[(int)JsonUIFloatType.linearClipOnEnableDuration] = comp.clipDuration;
                    data.linearClipOnEnableCurveType = (int)comp.curveType;
                }
                if (each.GetComponent<LinearClipOnDisable>())
                {
                    var comp = each.GetComponent<LinearClipOnDisable>();
                    data.customFlags2 |= (int)UIExportFlag2.LinearClipOnDisable;
                    data.linearClipOnDisableStart = new float[] { comp.startPos.x, comp.startPos.y };
                    data.linearClipOnDisableDir = new float[] { comp.clipDirection.x, comp.clipDirection.y };
                    data.floats[(int)JsonUIFloatType.linearClipOnDisableDuration] = comp.clipDuration;
                    data.linearClipOnDisableCurveType = (int)comp.curveType;
                }
                if (each.GetComponent<IsDuplicatable>())
                {
                    var comp = each.GetComponent<IsDuplicatable>();
                    data.customFlags2 |= (int)UIExportFlag2.Duplicatable;
                    data.duplicate_poolable = comp.poolable;
                }
                if (each.GetComponent<BarCells>())
                {
                    var comp = each.GetComponent<BarCells>();
                    data.customFlags2 |= (int)UIExportFlag2.IsBarCells;
                    data.floats[(int)JsonUIFloatType.barCells_BarWidth] = comp.gaugeFill.rectTransform.sizeDelta.x;
                    data.floats[(int)JsonUIFloatType.barCells_BarHeight] = comp.gaugeFill.rectTransform.sizeDelta.y;
                    data.barCells_CellNumber = comp.cellCount;
                    data.floats[(int)JsonUIFloatType.barCells_GaugePerCell] = comp.gaugePerCell;
                }
                if (each.GetComponent<AdjustLinearClip>())
                {
                    var comp = each.GetComponent<AdjustLinearClip>();
                    data.customFlags2 |= (int)UIExportFlag2.AdjustLinearClip;
                    data.floats[(int)JsonUIFloatType.adjustLinearClipDirectionX] = comp.clipDirection.x;
                    data.floats[(int)JsonUIFloatType.adjustLinearClipDirectionY] = comp.clipDirection.y;
                    data.floats[(int)JsonUIFloatType.adjustLinearClipStartX] = comp.clipStart.x;
                    data.floats[(int)JsonUIFloatType.adjustLinearClipStartY] = comp.clipStart.y;
                    data.floats[(int)JsonUIFloatType.adjustLinearClipAdjustingRate] = comp.adjustingRate;
                }
                if (each.GetComponent<UIPlayMusicOnEnable>())
                {
                    var comp = each.GetComponent<UIPlayMusicOnEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.MusicPlayOnEnable;
                    data.floats[(int)JsonUIFloatType.musicPlayOnEnable_fadeIn] = comp.fadeInTime;
                    data.floats[(int)JsonUIFloatType.musicPlayOnEnable_fadeOut] = comp.fadeOutTime;
                    if (comp.clip != null)
                    {
                        data.musicPlayOnEnable_musicClip = AssetDatabase.GetAssetPath(comp.clip);
                        data.musicPlayOnEnable_musicClip = data.musicPlayOnEnable_musicClip.Replace("Assets/Resources/", "");
                    }
                }
                if (each.GetComponent<UIPlayMusicOnDisable>())
                {
                    var comp = each.GetComponent<UIPlayMusicOnDisable>();
                    data.customFlags2 |= (int)UIExportFlag2.MusicPlayOnDisable;
                    data.floats[(int)JsonUIFloatType.musicPlayOnDisable_fadeIn] = comp.fadeInTime;
                    data.floats[(int)JsonUIFloatType.musicPlayOnDisable_fadeOut] = comp.fadeOutTime;
                    if (comp.clip != null)
                    {
                        data.musicPlayOnDisable_musicClip = AssetDatabase.GetAssetPath(comp.clip);
                        data.musicPlayOnDisable_musicClip = data.musicPlayOnDisable_musicClip.Replace("Assets/Resources/", "");
                    }
                }
                if (each.GetComponent<MusicUnpauseOnDisable>())
                {
                    data.customFlags2 |= (int)UIExportFlag2.MusicUnpauseOnDisable;
                }
                if (each.GetComponent<MusicPauseOnEnable>())
                {
                    data.customFlags2 |= (int)UIExportFlag2.MusicPauseOnEnable;
                }
                if (each.GetComponent<MusicMultiplyVolumeOnEnableDisable>())
                {
                    var comp = each.GetComponent<MusicMultiplyVolumeOnEnableDisable>();
                    data.customFlags2 |= (int)UIExportFlag2.MusicMultiplyVolumeOnEnableDisable;
                    data.floats[(int)JsonUIFloatType.musicMultiplyVolumeOnEnableDisable_enableFactor] = comp.enableFactor;
                    data.floats[(int)JsonUIFloatType.musicMultiplyVolumeOnEnableDisable_fadeDuration] = comp.fadeDuration;
                }
                //        ExclusiveEnable = 1 << 10,
                //DisableAfterEnable = 1 << 11,
                //Dialogue_Manual = 1 << 12,
                //Dialogue_Timed = 1 << 13,
                //RedundantEnable = 1 << 14,
                if (each.GetComponent<ExclusiveEnable>())
                {
                    var comp = each.GetComponent<ExclusiveEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.ExclusiveEnable;
                    data.exclusiveEnableGroup = comp.exclusiveGroup.Select(each => uiIDDictanary[each]).ToArray();
                }
                if (each.GetComponent<DisableAfterEnable>())
                {
                    var comp = each.GetComponent<DisableAfterEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.DisableAfterEnable;
                    data.floats[(int)JsonUIFloatType.disableAfterEnable_delayUntilDisable] = comp.delayUntilDisable;
                }
                if (each.GetComponent<Dialogue_Manual>())
                {
                    var comp = each.GetComponent<Dialogue_Manual>();
                    if (foundManualDialogues.Contains(comp.gameObject.name))
                    {
                        Debug.LogError("Dialogue_Manual이 중복됩니다! : ", each);
                        failedAlready = true;
                        break;
                    }
                    foundManualDialogues.Add(comp.gameObject.name);
                    data.customFlags2 |= (int)UIExportFlag2.Dialogue_Manual;
                }
                if (each.GetComponent<Dialogue_Timed>())
                {
                    var comp = each.GetComponent<Dialogue_Timed>();
                    if (foundTimedDialogues.Contains(comp.gameObject.name))
                    {
                        Debug.LogError("Dialogue_Timed이 중복됩니다! : ", each);
                        failedAlready = true;
                        break;
                    }
                    foundTimedDialogues.Add(comp.gameObject.name);
                    data.customFlags2 |= (int)UIExportFlag2.Dialogue_Timed;
                }
                if (each.GetComponent<ScriptUI>())
                {
                    var comp = each.GetComponent<ScriptUI>();
                    if (foundScriptUI.Contains(comp.gameObject.name))
                    {
                        Debug.LogError("ScriptUI의 이름이 중복됩니다! : ", each);
                        failedAlready = true;
                        break;
                    }
                    foundScriptUI.Add(comp.gameObject.name);
                    data.customFlags2 |= (int)UIExportFlag2.ScriptUI;
                }
                if (each.GetComponent<SoundEnumComp>() && each.GetComponent<SoundEnumComp>().soundEnumID != SoundEnumComp.SoundEnumID.None)
                {
                    var comp = each.GetComponent<SoundEnumComp>();
                    if (foundSounds.Contains(comp.soundEnumID))
                    {
                        Debug.LogError("sound id가 중복됩니다! : ", each);
                        failedAlready = true;
                        break;
                    }
                    foundSounds.Add(comp.soundEnumID);
                    data.soundEnumID = (int)comp.soundEnumID;
                    data.customFlags2 |= (int)UIExportFlag2.EnumSound;
                }
                if (each.GetComponent<RedundantEnable>())
                {
                    var comp = each.GetComponent<RedundantEnable>();
                    data.customFlags2 |= (int)UIExportFlag2.RedundantEnable;
                }
                if (each.GetComponent<RotatingUI>())
                {
                    var comp = each.GetComponent<RotatingUI>();
                    data.customFlags2 |= (int)UIExportFlag2.Rotating;
                    data.floats[(int)JsonUIFloatType.rotatingSpeed] = comp.rotateSpeed;
                    data.floats[(int)JsonUIFloatType.rotatingInitialRotation] = comp.transform.localRotation.eulerAngles.z;
                }
                if (each.GetComponent<StartGameButton>())
                {
                    var comp = each.GetComponent<StartGameButton>();
                    data.customFlags2 |= (int)UIExportFlag2.StartGameButton;
                }
                if (each.GetComponent<ReturnToTitleButton>())
                {
                    var comp = each.GetComponent<ReturnToTitleButton>();
                    data.customFlags2 |= (int)UIExportFlag2.ReturnToTitleButton;
                }
                if (each.GetComponent<Video>())
                {
                    var comp = each.GetComponent<Video>();
                    data.customFlags2 |= (int)UIExportFlag2.Video;
                    data.floats[(int)JsonUIFloatType.videoDuration1] = comp.videoDuration1;
                    data.videoUnscaledDeltaTime = comp.isDeltatimeUnscaled;
                    if (comp.video1 != null)
                    {
                        data.videoPath1 = AssetDatabase.GetAssetPath(comp.video1);
                        data.videoPath1 = data.videoPath1.Replace("Assets/Resources/", "");
                    }
                    if (comp.video2 != null)
                    {
                        data.videoPath2 = AssetDatabase.GetAssetPath(comp.video2);
                        data.videoPath2 = data.videoPath2.Replace("Assets/Resources/", "");
                    }
                }
                if (each.GetComponent<AnimatedSprite>())
                {
                    var comp = each.GetComponent<AnimatedSprite>();
                    data.customFlags2 |= (int)UIExportFlag2.AnimatedSprite;
                    data.animatedSpriteFolderPath = AssetDatabase.GetAssetPath(comp.Sprite);
                    data.animatedSpriteFolderPath = Path.GetDirectoryName(data.animatedSpriteFolderPath);
                    data.animatedSpriteFolderPath = data.animatedSpriteFolderPath.Replace("\\", "/");
                    data.animatedSpriteFolderPath = data.animatedSpriteFolderPath.Replace("Assets/Resources/", "");
                    data.animatedSpriteIsRepeat = comp.repeat;
                    Debug.Log(data.animatedSpriteFolderPath);
                }
                if (each.GetComponent<MultiLanguageImage>())
                {
                    var comp = each.GetComponent<MultiLanguageImage>();
                    data.customFlags2 |= (int)UIExportFlag2.MultiLanguage;
                    data.koreanImagePath = AssetDatabase.GetAssetPath(comp.koreanImg);
                    data.koreanImagePath = data.koreanImagePath.Replace("Assets/Resources/", "");
                    data.englishImagePath = AssetDatabase.GetAssetPath(comp.englishImg);
                    data.englishImagePath = data.englishImagePath.Replace("Assets/Resources/", "");
                    data.japaneseImagePath = AssetDatabase.GetAssetPath(comp.japaneseImg);
                    data.japaneseImagePath = data.japaneseImagePath.Replace("Assets/Resources/", "");
                }
                if (each.GetComponent<UIEnumIDComp>())
                {
                    var id = each.GetComponent<UIEnumIDComp>().uiEnumID;
                    data.enumID = (int)id;

                    if (id != UIEnumIDComp.UIEnumID.None && foundUIIds.Contains(id))
                    {
                        Debug.LogError("UI Enum ID가 다음 객체에서 중복됩니다! : ", each);
                    }
                    var parentUI = each.transform.parent;
                    bool skip = false;
                    while (parentUI)
                    {
                        if (parentUI.GetComponent<IsDuplicatable>())
                        {
                            skip = true;
                            break;
                        }
                        parentUI = parentUI.transform.parent;
                    }
                    if (!skip)
                    {
                        foundUIIds.Add(id);
                    }
                }
                uilist.dataList.Add(data);
            }
            catch (Exception e)
            {
                Debug.LogError(e.Message, each);
                failedAlready = true;
                break;
            }
        }
        if (!failedAlready)
        {
            jsonString = JsonUtility.ToJson(uilist, true);
            System.IO.File.WriteAllText(path, jsonString);
        }
    }
    private static void DFS(GameObject gameObject, string tagName, List<GameObject> list, Dictionary<GameObject, int> uiIDDict)
    {
        if (gameObject.tag == tagName)
        {
            list.Add(gameObject);
            uiIDDict.Add(gameObject, uiIDDict.Count);
        }
        foreach (Transform each in gameObject.transform)
        {
            DFS(each.gameObject, tagName, list, uiIDDict);
        }
    }
}

 

 

 

 

 

8. 성과

 

8.1. 게임 플레이 영상

 

8.2. 개발 과정 영상

 

8.3. 도쿄 게임 쇼

Tokyo Game Show 2024 행사에 게임이 전시된 모습

 

9. 느낀 점

 팀원이 지속가능한 코드를 개발할 수 있는 역량이 있는지 냉정하게 판단하는 것이 중요하다. 기술적 문제를 자존심 문제와 별개로 생각해야 한다. 타인의 기분을 배려해야 한다는 명분으로 필요한 판단을 제때 내리지 못하면 이는 나중에 더 큰 재앙이 되어 돌아온다.

 내 말과 권위에 주눅드는 사람, 순응하는 사람보다는 나에게 따지고 들고, 추궁하는 사람이 낫다. 프로젝트에 진지하게 임하려는 사람만이 미주알 고주알을 따진다.

  일에 가시적인 성과가 보이지 않는 채로 프로젝트 기간이 늘어지면 모두가 지치고, 지친 팀원들은 유지보수가능한 코드를 짤 수 없다. 유지보수가 힘든 코드가 쌓이다 보면 결국 프로젝트 자체가 돈좌된다. 사람의 근성과 열정은 유한하다. 팀원의 역량을 객관적으로 파악하고, 공세종말점을 잘 계산한 다음 일정을 세워야 한다.

 항상 정신적인 여유를 가지고 판단력을 유지해야 한다. 운동 + 독서 + 명상 + 개발이 순수 개발보다 효율이 더 좋다. 마음이 욕심, 분노, 어리석음으로 가득 차 있었기 떄문에 일을 그르칠 뻔 했던 적이 한두번이 아니다.

 분할 컴파일, 분할 테스팅에 대한 이해가 확실히 필요하다. 매번 게임을 테스트할 때마다 어마어마한 빌드 시간 + 로딩 시간을 거치니 정신이 나태해지고 업무 효율이 떨어진다.

 테스트 자동화는 테스트케이스들도 계속 업데이트해줘야 하는 단점이 극명하게 있다.