[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 33회차 미션 시작합니다.

04. 배틀로얄 - 11, 12 번을 진행합니다.


저번 시간에 이어 Save, Add, Remove, Copy 등의 구현을 시작합니다.




아래 진행하는 코드 중 ArrayHelper라는 클래스는 제공되는 프로젝트에 포함되어 있는 코드를 사용합니다.
Array 기능을 좀더 쉽게 사용할 수 있는 기능을 가진 함수 모음 클래스라고 생각하면 됩니다.
크게 중요하지 않은 내용이므로 제공된 소스를 사용만 하면 됩니다. Tool용 ArrayHelper 입니다.



public class EffectData : DataBase
{
    // 저번시간코드들..
    
    public void SaveData() {
        using (XmlTextWriter xml = new XmlTextWriter(xmlFilePath + xmlFileName, System.Text.Encoding.Unicode) {
            xml.WriteStartDocument();
            xml.WriteStartElement(EFFECT); // EFFECT Key
            xml.WriteElementString("length", GetDataCount().ToString());
            for (int i = 0; i < this.names.Length; i++) {
                EffectClip clip = this.effectClips[i];
                xml.WriteStartElement(CLIP);  // CLIP Key
                xml.WriteElementString("id", i.ToString());
                xml.WriteElementString("name", this.names[i]);
                xml.WriteElementString("effectType", clip.effectType.ToString());
                xml.WriteElementString("effectPath", clip.effectPath);
                xml.WriteElementString("effectName", clip.effectName);
                xml.WriteEndElement(); // CLIP End
            }
            xml.WriteEndElement(); // EFFECT End
            xml.WriteEndDocument();
        }
    }
   
    public override int AddData(string newName) {
        if (this.names == null) {
            this.names = new string[] { name };
            this.effectClips = new EffectClip[] { new EffectClip() };
        }
        else {
            this.names = ArrayHelper.Add(name, this.names);
            this.effectClips = ArrayHelper.Add(new EffectClip(), this.effectClips);
        }
        return GetDataCount();
    }
    
    public override void RemoveData(int index) {
        this.names = ArrayHelper.Remove(index, this.names);
        if (this.names.Length == 0) this.names = null;
        this.effectClips = ArrayHelper.Remove(index, this.effectClips);
    }
    
    public void ClearData() { // Bonus function
        foreach (EffectClip clip in this.effectClips) {
            clip.ReleaseEffect();
        }
        this.effectClips = null;
        this.names = null;
    }
    
    public EffectClip GetCopy(int index) {
        if (index < 0 || index >= this.effectClips.Length) return null;
        EffectClip original = this.effectClips[index];
        EffectClip clip = new EffectClip();
        clip.effectFullPath = original.effectFullPath;
        clip.effectName = original.effectName;
        clip.effectType = original.effectType;
        clip.effectPath = original.effectPath;
        clip.realId = this.effectClips.Length;
        return clip;
    }
    
    /// <summary>
    /// 원하는 인덱스를 프리로딩해서 찾아준다
    /// </summary>
    public EffectClip GetClip(int index) {
        if (index < 0 || index >= this.effectClips.Length) return null;
        effectClips[index].PreLoad();
        return effectClips[index];
    }
    
    public override void Copy(int index) {
        this.names = ArrayHelper.Add(this.names[index], this.names);
        this.effectClips = ArrayHelper.Add(GetCopy(index), this.effectClips);
    }
}


여기까지가 EffectData 에 대한 처리 마무리였고 이제부터 본격적인 Tool 개발에 들어갑니다 ^^~





공통 툴 레이어 작성입니다.

본격적인 EffectTool 제작 전에, 공통 툴을 만들어두면 다른 툴을 만들때에도 재사용 가능하기 때문에 제작해두는 것이 좋습니다.




EditorHelper 클래스입니다.

ArrayHelper와 비슷하게 Tool 개발을 용이하게 해주는 함수들의 모음이며 프로젝트에 포함되어 배포되어 있습니다.

다음의 2가지 함수는 구현되어 있고, 나머지 함수들을 추가 구현합니다.

+ GetPath() - Resource를 전달하면 Resource의 경로를 리턴해주는 함수
+ CreateEnumStructure() - Enum 객체를 만들어주는 함수

 


public class EditorHelper
{
    public static string GetPath(UnityEngine.Object p_clip) {}
    public static void CreateEnumStructure(string enumName, StringBuilder data) {}
    
    // Tool UI 상단
    public static void EditorToolTopLayer(BaseData data, ref int selection, ref UnityObject source, int uiWidth) {
        EditorGUILayout.BeginHorizontal();
        {
            if (GUILayout.Button("ADD", GUILayout.Width(uiWidth))) {
                data.AddData("New Data");
                selection = data.GetDataCount() - 1; // 최종 Item 선택
                source = null;
            }
            if (GUILayout.Button("COPY", GUILayout.Width(uiWidth))) {
                data.Copy(selection);
                source = null;
                selection = data.GetDataCount() - 1;
            }
            if (data.GetDataCount() > 1) {
                if (GUILayout.Button("REMOVE", GUILayout.Width(uiWidth))) {
                    source = null;
                    data.RemoveData(selection);
                }
            }
            
            if (selection > data.GetDataCount() - 1) { // out of range
                selection = data.GetDataCount() - 1;
            }
        }
        EditorGUILayout.EndHorizontal();
    }
    
    // Tool UI 리스트
    public static void EditorToolListLayer(ref Vector2 ScrollPosition, BaseData data, ref int selection, ref UnityObject source, int uiWidth) {
        EditorGUILayout.BeginVertical(GUILayout.Width(uiWidth);
        {
            EditorGUILayout.Separator();
            EditorGUILayout.BeginVertical("box");
            {
                ScrollPosition = EditorGUILayout.BeginScrollView(ScrollPosition);
                {
                    if (data.GetDataCount() > 0) {
                        int lastSelection = selection;
                        selection = GUILayout.SelectionGrid(selection, data.GetNameList(true), 1);
                        if (lastSelection != selection) { // 선택이 바뀌었으면
                            source = null;
                        }
                    }
                }
                EditorGUILayout.EndScrollView();
            }
            EditorGUILayout.EndVertical();
        }
        EditorGUILayout.EndVertical();
    }
}


다음시간에 공통 툴 레이어 완성 및 본격적인 EffectTool 만들기를 시작합니다 ^^~




<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 32회차 미션 시작합니다.

04. 배틀로얄 - 09, 10 번을 진행합니다.


저번에 이어서 이번에는 EffectData 클래스를 구현합니다.




코드 내용중 clipPath는 위에서 보듯이 Unity Project에서 "9.ResourcesData/Resources/Prefabs/Effects" 폴더를 가리킵니다.




마찬가지로 코드 내용중 dataPath는 위에서 보이는 Unity Project에서 "9.ResourcesData/Resources/Data" 폴더를 가리킵니다.



using System.Xml; // XML 사용.
using System.IO; // 읽기 쓰기.
/// <summary>
/// 이팩트 클립 리스트와 이팩트 파일 이름과 경로를 가지고 있으며 파일을 읽고 쓰는 기능을 가지고 있다.
/// </summary>
public class EffectData : BaseData
{
    // Array는 실수로 무한정 커질 수 있는 문제를 가지고 있어서 한정된 자원이라는 의미와 크기 명확성을 위해 배열을 사용.
    public EffectClip[] effectClips = new EffectClip[0];
    
    public string clipPath = "Effects/";
    private string xmlFilePath = ""; // Path, File 분리이유는 경로만 바꾸어 파일관리, 버전관리가 용이하며, 특정 Asset 폴더로 관리하는 등 유연하기 때문.
    private string xmlFileName = "effectData.xml";
    private string dataPath = "Data/effectData";
    //XML 구분자.
    private const string EFFECT = "effect"; // 저장 KEY
    private const string CLIP = "clip"; // 저장 KEY
    
    private EffectData() { }
    
    // 읽어오고 저장하고, 데이터를 삭제하고, 특정 클립을 얻어오고, 복사하는 기능.
    public void LoadData() {
        Debug.Log($"xmlFilePath = {Application.dataPath} + {dataDirectory}");
        this.xmlFilePath = Application.dataPath + dataDirectory; // Application.dataPath = Unity Assets Folder
        TextAsset asset = (TextAsset)ResourceManager.Load(dataPath);
        if (asset == null || asset.text == null) { // 하나도 없는 경우
            this.AddData("new Effect");
            return;
        }
        
        using (XmlTextReader reader = new XmlTextReader(new StringReader(asset.text))) {
            int currentID = 0;
            while (reader.Read()) {
                if (reader.IsStartElement()) {
                    switch (reader.Name) {
                        case "length":
                            int length = int.Parse(reader.ReadString());
                            this.names = new string[length];
                            this.effectClips = new EffectClip[length];
                            break;
                        case "id":
                            currentID = int.Parse(reader.ReadString());
                            this.effectClips[currentID] = new EffectClip();
                            this.effectClips[currentID].realId = currentID;
                            break;
                        case "name":
                            this.names[currentID] = reader.ReadString();
                            break;
                        case "effectType":
                            this.effectClips[currentID].effectType = (EffectType)Enum.Parse(typeof(EffectType), reader.ReadString());
                            break;
                        case "effectName":
                            this.effectClips[currentID].effectName = reader.ReadString();
                            break;
                        case "effectPath":
                            this.effectClips[currentID].effectPath = reader.ReadString();
                            break;
                    }
                }
            }
        }
    }
}


초반 개발시에는 데이터 파일 저장 포맷이 지속적으로 바뀌게 될텐데 이때부터 json을 사용해도 되지만 json을 사용하게 되는 경우 무언가 잘못되어 꼬이게 되는 경우 저장 구조가 복잡하여 해당 문제점을 찾기가 어렵다는 단점이 있습니다.
그래서 초반에는 xml과 같은 명확하고 찾기 쉬운 데이터 파일 구조를 사용하다가 어느 정도 안정화되면 json으로 변경하는 것도 좋은 방법입니다.

사실 실무에서는 자체 개발된 데이터 파일 구조를 많이 사용한다고 합니다.




위와 같이 $를 붙여서 사용하면 좀더 편하게 문자열 구조를 사용할 수 있습니다.
지원되지 않았을 때는 아래 문장처럼 +를 마구마구 써서 연결해야 했던 불편함이 컸었지요.

C#은 정말 개발자 편의성을 정말 많이 고려한 것 같습니다.

 


그런데 왜 1.2x를 없앤거죠?? 1x 1.5x 2x만 나오는... 갑자기 오늘부터 지원을 안하네요.. 라고 하고 있는데 잠시 그랬던거 같네요 ^^;;;
다시 불러와서 재생하니 정상적으로 나오네요.. 서버 작업중이신건가 ㅎㅎ
헉.. 그런가보네요.. 갑자기 저화질로 나오고 서버 일시오류라고 뜨며 멈추고 튕기기도 하네요 ㅠ.,ㅜ;; 무어지.. 뜨합..
어여 안정화시켜주세요..




<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 31회차 미션 시작합니다.

04. 배틀로얄 - 07, 08 번을 진행합니다.





일단 Assets/1.Scripts 폴더에 대한 간단한 설명입니다.

+ Common - 공통적인 스크립트들
+ GameData - GameAsset에 쓰일 데이터 스크립트들
+ Helper - 에디터, 툴 Helper 스크립트들
+ Tool - 툴 스크립트들
+ Util - 시간계산, 위치계산 등의 유틸 스크립트들

+ Manager 폴더를 만들고, C# 스크립트 ResourceManager를 추가합니다.


using UnityEngine;
using UnityObject = UnityEngine.Object; // Object를 어디것인지 명확히 하여 사용.
/// <summary>
/// Resources.Load를 래핑하는 클래스
/// 나중엔 어셋번들로 변경됨.
/// </summary>
public class ResourceManager
{
    // ResourceManager 클래스를 Singletone 패턴으로 구현해도 되지만, 간단한 클래스는 그냥 함수를 static으로 해서 지원해도 편하다.
    public static UnityObject Load(string path) {
        // 지금은 리소스 로드지만 추후엔 어셋 로드로 변경됨.
        return Resources.Load(path);
    }
    
    public static GameObject LoadAndInstantiate(string path) {
        UnityObject source = Load(path);
        if (source == null) return null;
        return GameObject.Instantiate(source) as GameObject;
    }
}





이팩트 툴 제작을 위한 이팩트 데이터 모델을 먼저 제작합니다.
여기 내용들은 게임 장르와 기획에 따라 엄청나게 바뀌게 됩니다. 이 툴을 사용하게 되는 분들은 기획자나 밸런서분들이 될 것입니다. 그래서 요구사항도 많아지게 될 것입니다.

불필요한듯하고 굉장히 손도 많이 가는 툴을 왜 만드느냐고 의문을 가지는 개발자들이 많은 듯하네요..
저는 무조건 찬성입니다. 무조건 Tool을 개발해야 합니다. 게임뿐만이 아니라 어떠한 프로그램을 만들더라도 마찬가지라고 생각합니다. 이러한 AddOn Tool이든 Helper Tool이든 마찬가지라고 생각합니다.

Tool을 만들다 보면 3번의 고비가 온다고 합니다. ㅋㅋㅋㅋㅋㅋㅋㅋ.
사운드 툴 만들 때, 캐릭터 만들 때, 그리고 애니메이션 만들 때라고 합니다. ㅎㅎ 잘 넘기고 이겨내면 멋진 게임을 개발해 낼 수가 있게 되는 것이겠지요..


<Effect Clip>
+ Prefab - 터지는 효과, 피격 효과, 터지는 Mesh 등
+ 생명주기 - 몇초후에 사라져라 또는 화면 밖으로 넘어가면 사라져라 등
+ 위치 - 캐릭터의 손에 붙는 이팩트인가, 피격이팩트.
+ 사운드 등등..


<Effect Data>
+ Effect Clip들의 모음





EffectClip과 EffectData 스크립트를 추가합니다.

Effect Tool은 그래도 간단한 편입니다. Sound Tool로 가면 많이 복잡해진다고 합니다 ^^; 기대도 되고 긴장도 되네요.



/// <summary>
/// 이팩트 프리팹과 경로와 타입 등의 속성 데이터를 가지고 있게 되며
/// 프리팹 PreLoading 기능을 갖고 있고 - 풀링을 위한 기능이기도 합니다.
/// 이팩트 인스턴스 기능도 갖고 있으며 - 풀링과 연계해서 사용하기도 합니다.
/// </summary>
public class EffectClip
{
    public int realId = 0; // 추후 속성은 같지만 다른 이팩트 클립이 있을 수 있어서 분별용.
    
    public EffectType effectType = EffectType.NORMAL;
    public GameObject effectPrefab = null;
    public string effectName = string.Empty;
    public string effectPath = string.Empty;
    public string effectFullPath = string.Empty; // effectPath + effectName
    
    public EffectClip() { }
    
    public void PreLoad() {
        this.effectFullPath = effectPath + effectName;
        if (this.effectFullPath != string.mpty && this.effectPrefab == null) { // prefab 로딩이 되어있다면 다시 할 필요 없으므로
            this.effectPrefab = ResourceManager.Load(effectFullPath) as GameObject;
            //this.effectPrefab = (GameObject)ResourceManager.Load(effectFullPath); // 강제형변환을 앞에 넣어도 되지만 중요하지 않은 구문이 앞으로 오기 때문에 "as" 구문이 유용한 경우가 많다.
        }
    }
    
    public void ReleaseEffect() {
        if (this.effectPrefab != null)
            this.effectPrefab = null; // Destroy하지 않아도 GabageCollector가 처리할 것이다.
    }

    /// <summary>
    /// 원하는 위치에 내가 원하는 이팩트를 인스턴스합니다.
    /// </summary>
    public GameObject Instantiate(Vector3 Pos) {
        if (this.PreLoad() != null) {
            GameObject effect = GameObject.Instantiate(effectPrefab, Pos, Quaternion.identity);
            return effect;
        }
        
        return null;
    }
}





EffectType에서 "Ctrl + Click"을 하면 정의로 이동을 하게 됩니다.
GlobalDefine 스크립트는 위와 같은 내용을 가지고 있습니다.

 

걱정도 많으시고 받아들이는 사람에 대한 큰 배려심이기도 하셔서 반복적인 걱정 얘기를 많이하셔서 아직까지는 내용이 짧습니다. ㅎㅎ 강의 진행하시면서 개발 시간때문에라도 많이 빨라지지 않을까 싶습니다.

 

일단 화면도 잘 보이고.. VisualStudio 검정 배경에 글씨들도 잘 보입니다.

온라인 강의라 놓친 부분들은 다시 볼 수 있기 때문에 너무 걱정 안하시고 좋은 얘기와 내용 많이 해주시면 좋겠습니다.




<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 30회차 미션 시작합니다.

04. 배틀로얄 - 05, 06 번을 진행합니다.




"Assets/1.Scripts/GameData" 폴더 내에 C# Script를 "BaseData"로 하나 생성합니다.

ScriptableObject는 클래스 인스턴스와는 별도로 대량의 데이터를 저장하는데 사용할 수 있는 데이터 컨테이너입니다.

json을 실무에서 많이 사용하고 가장 좋지만, 이후 설명에서도 사용할 것이기 때문에 여기서는 비교 스터디 목적으로 xml 기반으로 구동되는 클래스를 작성합니다.

LinQ 구문도 가능하면 꼭 필요한 부분에서만 사용하겠다고 하시네요. 기존 C++ 개발자분들을 배려하시는 듯 합니다.



/// <summary>
/// data의 기본 클래스입니다.
/// 공통적인 데이트를 가지고 있게 되는데, 이름만 현재 갖고 있다.
/// 데이터의 갯수와 이름의 목록 리스트를 얻을 수 있다.
/// </summary>
public class BaseData : ScriptableObject
{
    public const string dataDirectory = "/9.ResourcesData/Resources/Data/";
    public string[] names = null; // 데이터를 읽기 전이라는 의미로 null 처리.
    
    public BaseData() { }
    
    public int GetDataCount() {
        int retValue = 0;
        if (this.names != null)
            retValue = this.name.Length; // Length는 Property, Count()는 확장 메서드. 실제 사용상 차이 있음.
        return retValue;
    }
    
    /// <summary>
    /// 툴에 출력하기 위한 이름 목록을 만들어주는 함수.
    /// </summary>
    public string[] GetNameList(bool showID, string filterWord = "") {
        string[] retList = new string[0];
        if (this.names == null) return retList;
        
        retList = new string[this.names.Length];
        for(int i = 0; i < this.names.Length; i++) {
            if (filterWord != "") {
                if (names[i].toLower().Contains(filterWorld.ToLower()) == false) {
                    continue;
                }
            }
            if (showID) {
                retList[i] = i.ToString() + " : " + this.names[i]; // index를 붙여서 보여주기.
            }
            else {
                retList[i] = this.names[i];
            }
        }
        return retList;
    }
    
    public virtual int AddData(string newName) {
        return GetDataCount();
    }
    
    public virtual void RemoveData(int index) {
    }
    
    public virtual void Copy(int index) {
    }
}





///를 입력하여 summary를 입력해줌으로써 다른 개발자가 해당 클래스를 사용하려고 할 때 많은 도움이 되기 때문에 현업에서는 사용하는 경우가 많습니다. 지금은 클래스가 몇 개 되지 않아 큰 도움이 될 것같이 보이진 않아도, 나중에 프로젝트가 커져서 클래스, 함수가 많아지게 되면 도움이 될 것입니다.

대신 summary를 입력해놓고 열심히 개발하다보니 클래스, 함수의 내용이 많이 변하였는데 summary 내용이 업데이트되지 않으면 오히려 혼선만 발생할 수 있습니다. 그래서 그럴거면 아예 달지 말자는 얘기도 있다고 합니다만...

너무 모든 곳에 다 설명을 달려고 하지 말고 클래스와 주요 함수 정도에는 달아주는 것이 좋습니다. 대신 함수를 업데이트하게 된다면 꼭 summary 내용을 확인하여 위배된다면 summary도 업데이트 해주는 것을 잊지말아야겠습니다.

summary는 협업에 있어서는 매너라고 생각하면 될 것 같습니다.



소스코드내 dataDirectory는 위와 같이 Unity Project에 있는 "Data" 폴더를 가리키게 됩니다.


 


"Edit - Preferences.." 에서 "External Tools"에서 "External Script Editor"가 설정되어 있어야 Unity와 Editor가 정상적으로 연동되고 컴파일됩니다.

실무에서는 "Rider(유료)"를 많이 사용하는데 여기서는 "Visual Studio 2019 (Community)" 버전을 사용하여 진행합니다.

 

 

여기까지 기반이 되는 BaseData 클래스를 제작하였고, 다음 시간에는 실제 Tool 제작에 들어가겠네요 ^^~

 




<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 29회차 미션 시작합니다.

04. 배틀로얄 - 03, 04 챕터를 진행합니다.





실제 게임을 만드는 기법들을 최대한 활용하여 알찬 강의를 준비하겠다고 하십니다 ^^~ 좋네요~

미리 만들어서 배포해주신 프로젝트에 대한 설명을 시작합니다. 유니티로 해당 프로젝트를 열어서 보면 됩니다.

해당 Scene은 "2.Scenes" - "PlayGround" Scene을 열어 둔 화면입니다.


우측 상단의 "Layout"은 "Default"로 선택된 상태에서 Customize 하여 사용합니다.

창들을 드래그하여 배치하면 됩니다.

좌측상단은 "Scene"과 "Console" 창을 배치합니다.
좌측하단은 "Game Scene"을 배치합니다. "Asset Store"는 느리기 때문에 닫아줍니다.
우측에는 "Hierarchy", "Project", "Inspector"를 화면과 같이 설정해 줍니다.

최신 버전에서는 "2 by 3" Layout을 선택하고 조금 수정하면 됩니다.





"Edit" - "Preferences..."를 클릭하여 "General"에서 "Show Asset Store search hits [ ]"를 Uncheck 해줍니다.

개발 속도에서 차이가 있기 때문에 조금이라도 쾌적한 환경에서 작업하기 위한 팁입니다.





또 위와 같이 Hierarchy, Project, Inspector를 나란히 배치하기도 합니다.
Project는 1 Column으로 설정해 줍니다. 이렇게 하면 Hierarchy와 Project간에 GameObject를 설정하기가 수월해집니다.



일반적인 게임만드는 순서입니다.
1. 어떤 게임을 만들까
2. 엔진 선택 & 버전
3. 타겟 설정 (어느 디바이스, 스토어)
4. 타겟 디바이스와 유저
5. 게임 기준 설정: 프로그램 디자인간 단위 결정. 폴더 이름 규약. 기본 해상도 결정(16:9로 1920x1080, 1136x640, 4:3으로 1024:768)


배틀로얄 프로젝트는 FHD 즉 1920x1080 기준으로 PC에서 구현되는 게임을 기준으로 제작합니다.





위와 같이 "1920 x 1080" 해상도를 추가해 줍니다. 요즈음은 자동 변환 등 해상도 표현이 조금 나아지긴 했지만 타겟 해상도 결정은 중요한 사안입니다. 일단 기본 해상도로 시작해서 추후 다른 지원 해상도를 추가하는 형태를 고려하는 것이 좋습니다.



"Project" 폴더의 "Assets" 설명입니다.

1.Scripts
    /Common
        /AnimatorKey
        /ButtonName
2.Scenes
9.ResourcesData // 추후 3, 4 등 추가될 예정이라서..
PolygonBattleRoyale




 

 

 


Animator - Animator Parameters - AnimatorKey가 위와 같이 연동되어 구동됩니다.
문자열 오타로 인한 문제들의 가능성이 있기 때문에 상수로 설정하고 사용하는데 이것이 AnimatorKey 스크립트입니다.

실무에서는 이렇게 처리하여 SetParameter의 문자열 대신 해당 상수를 사용하여 호출합니다.

추가적으로 실무 게임 제작시에는 CharacterAnimatorKey, MonsterAnimatorKey, NPCAnimatorKey 등을 나누어 제작합니다.

namespace도 여러가지 편의성등을 위해 사용하는 것이 좋고, 대신 위의 "FC"를 사용할 필요는 없고 각자 게임에 맞는 namespace 이름을 정하여 제작하면 됩니다.




public class ButtonName
{
    public const string Sprint = "Sprint";
    public const string Jump = "Jump";
    public const string Aim = "Aim", Shoulder = "Aim Shoulder";
    public const string Shoot = "Fire1",
        Pick = "Interact",
        Change = "Change",
        Reload = "Reload",
        Drop = "Drop";
}




Unity - Edit - Project Settings... 에 보면 "Input Manager"에 사용 이름들이 정의되어 있습니다. 대소문자 구분되어야 하구요.

"Fire1" 같은 경우 명확한 구분이 안되기 때문에 게임내 "Shoot" 상수로 명확히 구분하여 혼선을 방지하고 있다고 생각하면 됩니다.




PolygonBattleRoyale 폴더는 Polygon Battle Royal Pack을 구매하여 해당 폴더에 설정해 준 것입니다.

강의에서는 꼭 필요한 것들만 사용되었으니 좀더 디테일하게 개발해보고 싶다면 해당 팩을 구매하여 사용하기를 추천합니다.

Tool 제작을 하여 사용하는데 아주 중요한 내용입니다.

Sound Tool, Effect Tool 2개를 만들어 사용할 예정입니다.



 

 

 



리소스 매니저는 리소스들을 적재하여 래핑해 놓은 것입니다.



제작하려는 Effect Tool, Sound Tool 2가지 툴 정보입니다.

+ 상단에는 Add, Copy, Remove를 수행합니다.
+ 좌측에는 요소 목록이 표시됩니다.
+ 우측에는 각 요소의 설정 기능이 표시됩니다.
+ 하단에는 "Reload Settings", "Save Settings" 버튼이 위치합니다.


Effect, Sound Tool도 각각 만드는 것이 아니라 Base Class를 만들고 상속을 통하여 재활용도를 높여서 사용합니다.

해당 기능을 Excel을 이용하는 경우도 많지만 오류/오타 등의 버그가 발생할 수 있고 이는 개발시간이 늘어지는 문제로 연결될 수 있습니다.
이렇듯 Tool을 만들어 놓으면 변환시간도 줄어들고 Unity에서 실행하면서 바로 값 변경을 하여 확인할 수 있기 때문에 결론적으로 개발시간의 단축 및 안정성도 높아진다고 볼 수 있겠습니다.

Simple is Best.. 제 삶의 모토 정한 문장인데.. 여기서 듣네요 ㅎㅎ..



<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 28회차 미션 시작합니다.

04. 배틀로얄 - 01, 02 챕터를 진행합니다.





오리엔테이션 시간입니다.




배틀로얄 게임 제작에 들어가기 전에 강사님에 대한 자세한 소개를 해주십니다.

다니셨던 회사들.. 유니티 강의.. 도서 출판.. 온라인 강좌.. 제작 게임 소개..

현업에서도 아낌없이 자료를 공유하는 것으로 유명하셨나 보네요.


 


최종적으로 만들게 될 배틀로얄 게임의 최종 모습입니다.

강의를 하게 된 계기도 소개해 주십니다. 현업에서도 활용가능하고 난이도가 높더라도 실제 개발시 필요한 내용들을 전달하고 싶으셨다고 하시네요.

앞선 강의에서도 보았듯이 현업에서 사용하는 내용들이 많이 나옵니다. 그만큼 어려워 보일 수 있고 오랜 시간동안 누적된 많은 정보량인데 한정된 강의에 넣으려니 얼마나 준비를 많이 하셨을까 싶습니다.



Q&A - 강의를 듣기 전에 미리 알아두어야 할 것들이 있나요?

입문 강의가 아니다보니 유니티 사용법을 하나하나 설명할 수는 없습니다. 배틀로얄 게임을 실제 제작하려고 해도 100~200시간은 걸리기 때문에 한정된 강의 시간에 설명을 모두 하며 제작을 하기는 어렵습니다.

Github 등에서 무료 유니티 게임을 다운받아 C# 소스를 많이 리딩하고, C# 슈팅 게임정도는 제작을 해본 경험이 필요합니다.

 


Q&A - 게임에 사용되는 리소스는 구입해야 하나요?

게임 강의를 위한 최소한의 리소스는 제공되기 때문에 구입할 필요 없습니다. 다만 좀더 풍부한 컨텐츠 제작을 원하거나 게임 제작 배포를 하겠다고 하면 에셋 구매를 하고 개발을 하면 됩니다.




게임 리소스 내려받기

배틀로얄 게임 제작시 최소 80시간은 들어갔다고 하시네요. 하지만 그것도 길기에 20시간 내에 가능하도록 7번이나 소스를 뒤엎으며 준비하셨다고 합니다. ^^~ 좋은 강의를 위한 노력 너무 감사드립니다.

게임 제작에 필수 기초적이면서도 꼭 필요한 사운드, 이펙트들은 게임들간 비슷할 수 있기에 하나하나 상세하게 설명하진 않습니다. 실무적으로 중요한 내용위주로 꼭 집어 설명할 예정입니다.





Git과 Sourcetree에 대해 간략히 설명해 주십니다. 리소스 준비하고 세팅하는데에도 시간이 걸리기 때문에 준비된 프로젝트를 Github에서 다운로드하여 해당 프로젝트에서 게임 개발을 시작하는 것입니다.

그리고 유니티를 설치해야겠지요.. Unity Hub로 다운로드.




Unity Version에 대한 문의가 많아 다른 버전을 설치하는 방법에 대해 설명해주십니다. ^^~

 

LTS : Long Time Service. 오랜 시간동안 서비스될 것이라는 의미. 따라서 장기 게임 프로젝트를 진행한다거나 할 때에는 LTS 버전을 사용하는 것이 안정성의 확보에 도움이 되겠습니다. 


* 알아두어야할 기본 지식
+ 유니티 애니메이션 시스템과 로코모션에 대해
+ 유니티 네비게이션 시스템에 대해

찾아보라고 말씀하시면서 직접 영상을 찾아 간단히 설명도 해주십니다. ㅎㅎ

DefaultAvatar라는 GameObject가 있다면 Animator 컴포넌트에 Avatar, Controller가 꼭 필요하며,
Avatar는 3D Modeling, Controller는 Animation 구동을 위한 데이터라고 생각하면 됩니다.

가장 중요한 건 디아블로 강의에서 배웠던 State 상태가 있고 Transition(이전)이 발생하며 다른 상태로 바뀌며 다른 Animation이 구동되는 시스템입니다.
가만히 있다가 -> 걷다가 -> 달리다가 등의 상태 변경인 것입니다.


마찬가지로 내비게이션 시스템을 위한 NavMesh를 간단히 설명해 주십니다.
이또한 디아블로 강의에서 설명이 있었지요. ㅎㅎ 그래서 강의 순서를 디아블로를 먼저 교육 내용으로 선정한 것 같습니다.


다음시간부터는 이러한 기초 내용을 알고 있다는 전제와 기초 프로젝트를 준비해놓은 상태에서 게임 제작을 시작합니다.





<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 27회차 미션 시작합니다.

03. Firebase 네트워크 - 05-2, 05-3 챕터를 진행합니다.


지난 시간에 이어 사용자 데이터/저장 불러오기 준비단계입니다.
Newtonsoft Json Library에 대한 소개로 시작합니다.




JsonUtility
  - Unity에 포함된 Json Library를 사용하는 것이 일반적입니다.
  - 최소한의 기능만 제공하고 있지만, Unity만을 위한 Vector serialize와 MonoBehaviour serialize를 제공합니다.
  - MonoBehaviour의 Deserialize는 별도의 제공함수인 FromJsonOverwrite()를 사용해야 합니다.

Newtonsoft Json
  - JsonUtility에서 제공되지 않는 추가적인 Json 기능들을 사용할 수 있는 외부 Library
  - BSON 지원 : Binary JSON
  - MonoBehaviour와 Vector에 대한 지원 클래스가 없으므로 별도로 설정하여 사용 필요.
  
추가적으로 JsonUtility는 Property에 대한 Serialize를 지원하지 않는데, Newtonsoft JSON은 Property Serialize가 동작하게 되므로, Property Json 변환을 원하지 않는다면 설정되지 않도록 Attribute를 설정하는 기능이 필요합니다.




Newtonsoft Json 다운로드 및 설정 방법을 정리하였습니다.

1) 다운로드
2) 압축해제하여 폴더 확인
3) Newtonsoft.Json.dll을 드래그하여 Unity Assets 폴더에 드랍하여 설정하면 됩니다.

여기서는 Newtonsoft Json을 사용하여 진행합니다.



Json 함수에 대한 설명입니다.




Json을 사용하는 테스트를 위해 UI를 미리 구현하였고, 나머지는 기존과 동일하며 [Save User Data] [Load User Data]가 추가되었습니다.
이를 클릭하면 User Data를 불러오고 저장하는 기능을 하게 됩니다.

실제 게임에서는 로그인을 하면 바로 Load User Data를 호출하여 사용자 데이터를 가져오면 되고,
사용자 데이터가 변경될 때마다 Save User Data를 호출하여 Firebase database에 데이터를 저장하는 루틴으로 구성하면 되겠습니다.



스크립트 기능 구현을 시작합니다.


public class PlayerDataHandler : MonoBehaviour
{
    private DatabaseReference databaseRef;
    private string UserDataPath => "users"; // 루트 폴더
    private string StatsDataPath => "stats"; // /users/uid/stats
    private string EquipmentDataPath => "equipment";
    private string InventoryDataPath => "inventory";
    
    public StatsObject playerStats;
    public InventoryObject playerEquipment;
    public InventoryObject playerInventory;
    
    void Start() {
        databaseRef = FirebaseDatabase.DefaultInstance.RootReference;
    }
    
    public void OnClickedSave() {
        var userId = FirebaseAuthManager.Instance.UserId;
        if (userId == string.Empty) return;
        
        string statsJson = playerStats.ToJson();
        databaseRef.Child(UserDataPath).Child(userId).Child(StatsDataPath).SetRawJsonValueAsync(statsJson).ContinueWith(task => {
            if (task.IsCanceled) { Debug.LogError("Save user data was canceled"); return; }
            if (task.IsFaulted) { Debug.LogError("Save user data encountered an error: " + task.Exception); return; }
            Debug.LogFormat("Save user data in successfully: {0} {1}", userId, statsJson);
        }); // users/uid/stats
       
        string equipmentJson = playerEquipment.ToJson();
        databaseRef.Child(UserDataPath).Child(userId).Child(EquipmentDataPath).SetRawJsonValueAsync(equipmentJson).ContinueWith(task => {
            if (task.IsCanceled) { Debug.LogError("Save equipment data was canceled"); return; }
            if (task.IsFaulted) { Debug.LogError("Save equipment data encountered an error: " + task.Exception); return; }
            Debug.LogFormat("Save equipment data in successfully: {0} {1}", userId, equipmentJson);
        });
       
        string inventoryJson = playerInventory.ToJson();
        databaseRef.Child(UserDataPath).Child(userId).Child(InventoryDataPath).SetRawJsonValueAsync(inventoryJson).ContinueWith(task => {
            if (task.IsCanceled) { Debug.LogError("Save inventory data was canceled"); return; }
            if (task.IsFaulted) { Debug.LogError("Save inventory data encountered an error: " + task.Exception); return; }
            Debug.LogFormat("Save inventory data in successfully: {0} {1}", userId, inventoryJson);
        });
    }
    
    public void OnClickedLoad() {
        var userId = FirebaseAuthManager.Instance.UserId;
        if (userId == string.Empty) return;
        
        databaseRef.Child(UserDataPath).Child(userId).Child(StatsDataPath).GetValueAsync().ContinueWith(task => {
            if (task.IsCanceled) { Debug.LogError("Load user data was canceled"); return; }
            if (task.IsFaulted) { Debug.LogError("Load user data encountered an error: " + task.Exception); return; }
            
            DataSnapshot snapshot = task.Result;
            // snapshot.Child(userId).Child(StatsDataPath).Child("level").GetValue .... // Json 미사용시 처리.. ㅋㅋ
            playerStats.FromJson(snapshot.GetRawJsonValue());
            Debug.LogFormat("Load user data in successfully: {0} {1}", userId, snapshot.GetRawJsonValue());
        });
        
        databaseRef.Child(UserDataPath).Child(userId).Child(EquipmentDataPath).GetValueAsync().ContinueWith(task => {
            if (task.IsCanceled) { Debug.LogError("Load equipment data was canceled"); return; }
            if (task.IsFaulted) { Debug.LogError("Load equipment data encountered an error: " + task.Exception); return; }
            
            DataSnapshot snapshot = task.Result;
            playerEquipment.FromJson(snapshot.GetRawJsonValue());
            Debug.LogFormat("Load equipment data in successfully: {0} {1}", userId, snapshot.GetRawJsonValue());
        });
        
        databaseRef.Child(UserDataPath).Child(userId).Child(InventoryDataPath).GetValueAsync().ContinueWith(task => {
            if (task.IsCanceled) { Debug.LogError("Load inventory data was canceled"); return; }
            if (task.IsFaulted) { Debug.LogError("Load inventory data encountered an error: " + task.Exception); return; }
            
            DataSnapshot snapshot = task.Result;
            playerInventory.FromJson(snapshot.GetRawJsonValue());
            Debug.LogFormat("Load inventory data in successfully: {0} {1}", userId, snapshot.GetRawJsonValue());
        });
    }
}



// 기존 게임 개발시 클래스로서 추가된 코드만 작성하였습니다.
public class StatsObject : ScriptableObject
{
    // 기존코드들..
    
    public string ToJson() {
        return JsonConvert.SerializeObject(levelData, Formatting.Indented);
    }
    
    public void FromJson(string jsonString) {
        PlayerLevelData newLevelData = JsonConvert.DeserializeObject<PlayerLevelData>(jsonString);
        Level = newLevelData.level;
        Exp = newLevelData.exp;
    }
}

public class InventoryObject : ScriptableObject
{
    // 기존코드들..
    
    public string ToJson() {
        return JsonConvert.SerializeObject(container, Formatting.Indented);
    }
    
    public void FromJson(string jsonString) {
        Inventory newContainer = JsonConvert.DeserializeObject<Inventory>(jsonString);
        Debug.Log("from json: " + newContainer.slots.Length);
        
        for (int i = 0; i < Slots.Length; i++) {
            Slots[i].UpdateSlot(newContainer.slots[i].item, newContainer.slots[i].amount);
        }
    }
}


이렇든 Json을 사용하면 다층화된 데이터 구조를 가지는 구조 데이터를 간결하게 구현할 수가 있습니다.





이제 Unity 플레이를 하여 로그인하고,
[Save User Data]를 진행하여 다음과 같이 Firebase Database에 저장된 데이터를 확인할 수 있습니다.




마찬가지로 Database 내에서 데이터 정보를 강제로 수정을 하고, 유니티에서 [Load User Data]를 진행하면 변경된 정보를 가져와서 업데이트되는 것을 확인할 수 있습니다.


복잡하고 어려운 듯한 강의였지만,

결론적으로 회원 가입 및 사용자 데이터 관리 등의 복잡한 기능을 해주는 Firebase를 잘 활용하자! 이고..
Json이라는 엄청난 Library를 이용하여 복잡하고 다계층화된 데이터의 접근 처리를 쉽게할 수 있다! 라고 결론지으면 되겠습니다.

오래전에 DataManager라는 동일한 기능을 하는 클래스를 만들어서 썼었는데.. Json을 썼으면 되는 거였네요.. ^^;;; 무지로 인한 고생인거였겠죠.. ㅎㅎ




<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 26회차 미션 시작합니다.

03. Firebase 네트워크 - 04-3, 05-1 챕터를 진행합니다.



저번 시간에 이어 LeaderboardController를 구현합니다.
금번 교육 내용만 작성하였습니다.




핵심은 score를 기준으로 데이터를 가져와서 정렬하여 처리하는 것입니다.

public class LeaderboardController : MonoBehaviour
{
    // 기존코드들..
    
    private bool sendUpdatedLeaderboardEvent = false;
    private event EventHandler<LeaderboardArgs> OnUpdatedLeaderboard;
    
    void Update() {
        // 기존 코드들..
        
        if (sendRetrievedScoreEvent) {
            sendRetrievedScoreEvent = false;
            OnRetrievedScore(this, retrievedScoreArgs);
        }
        
        if (sendUpdatedLeaderboardEvent) {
            sendUpdatedLeaderboardEvent = false;
            OnUpdatedLeaderboard(this, new LeaderboardArgs {
                scores = topScores,
                startDate = -1,
                endDate = -1
            });
        }
    }
    
    private bool gettingUserScore = false;
    public int GetUserScore(string userId) {
        gettingUserScore = true;
        databaseRef.Child(AllScoreDataPath) // 해당 Path에서
            .OrderByChild(UserScore.userIdPath) // Id Path 기준
            .StartAt(userId) // 시작부터
            .EndAt(userId) // 끝까지 취합
            .GetValueAsync().ContinueWith(task => {
                if (task.Exception !=) throw task.Exception;
                if (!task.IsCompleted) return;
                
                if (task.Result.Children == 0) {
                    retrievedScoreArgs = new UserScoreArgs(null, string.Format("No Scores for User {0}", userId);
                }
                else {
                    var scores = ParseValidUserScoreRecords(task.Result, -1, -1).ToList(); // 시간 내에 있는지
                    if (scores.Count == 0) {
                        retrievedScoreArgs = new UserScoreArgs(null, string.Format("No Scores for User {0} within time range {1} ~ {2}", userId, startDate, endDate);
                    }
                    else {
                        var orderedScored = scores.OrderBy(score => score.score);
                        var userScore = orderedScored.Last(); // 가장 큰값
                        retrievedScoreArgs = new UserScoreArgs(userScore, userScore.userId + " Retrieved!");
                    }
                }
                
                gettingUserScore = false;
                sendRetrievedScoreEvent = true; // Update에서 Event 발생.
            });
    }
    
    private List<UserScore> ParseValidUserScoreRecords(DataSnapShot snapshot, long startTicks, long endTicks) {
        return snapshot.Children
            .Select(scoreRecord => UserScore.CreateScoreFromRecord(scoreRecord))
            .Where(score => score != null && score.timestamp > startTicks && score.timestamp <= endTicks)
            .Reverse()
            .ToList();
    }
   
    
    private bool gettingTopScores = false;
    private void GetInitialTopScores() {
        gettingTopScores = true;
        var query = databaseRef.Child(AllScoreDataPath).OrderByChild("score");
        query = query.LimitToLast(20); // 20개
        query.GetValueAsync().ContinueWith(task => {
            if (task.Exception != null) { return; } // error
            if (!task.IsCompleted || !task.Result.HasChildren) return; // error
            
            var scores = ParseValidUserScoreRecords(task.Result, -1, -1);
            foreach (var userScore in scores) {
                if (!userScores.Contains(userScore.userId)) { // 기존값 존재하지 않는다면
                    userScores[userScore.userId] = userScore;
                }
                else { // 기존값 존재한다면
                    if (userScores[userScore.userId].score < userScore.score)
                        userScores[userScore.userId] = userScore; // score 갱신
                }
            }
            
            SetTopScores();
        });
    }
    
    private void SetTopScores() {
        topScores.Clear();
        topScores.AddRange(userScores.Values.OrderbyDecending(score => score.score));
        sendUpdatedLeaderboardEvent = true;
        gettingTopScores = false;
    }
}






Canvas에 "Leaderboard Handler"와 "Leaderboard UI Controller"를 Attach 시켰습니다.
플레이를 진행합니다.



화면과 같이 user_id, username, score를 입력하고 [Add] [Get User Score] [Update] 등의 버튼을 클릭하여, 사용자의 Score를 입력, 최대점수 얻기, Score 갱신 등을 테스트할 수 있습니다.

그리고 오른쪽의 [Get Leaderboard]를 클릭하면 최고점수의 user 부터 표시되는 것을 확인할 수 있습니다.

소스코드 예외 사항에 대한 처리는 동영상에는 없지만, 강의 자료에는 포함되어 있으므로 해당 소스를 보면 좀더 파악하기 좋을 것입니다.

여기까지 하여 기본적인 함수들을 이용하여 Leaderboard의 구현이 완료되었습니다.





이제 사용자의 데이터를 저장/불러오기 작업입니다.

firebase의 database 보안규칙

auth = 인증된 사용자 토큰.






권한 규칙을 설정할 수 있습니다.

"규칙 플레이그라운드"를 통해서 읽기/쓰기 권한에 대한 테스트를 해볼 수 있습니다.

인증이 된 경우에만 읽기/쓰기 작업이 되는 것 등을 확인할 수 있습니다.
또한 인증은 되었지만 다른 아이디로 테스트를 하는 경우에도 보안 규칙이 적용되어 에러가 발생하는 것 등을 확인할 수 있습니다.





위의 내용은 데이터 검증과 색인에 대한 내용입니다. 권한 규칙과 비슷해보이지만 다른 내용입니다. ㅎㅎ

.validate - 데이터가 올바른 형식인지 등
.indexOn - 개발상에서는 설정하지 않지만 Release시에는 규모가 커져도 정상 작동하도록 색인화를 하게됨. Database 용량이 많이 켜져도 쿼리 성능을 유지하기 위한 것이라고 보면 되겠습니다.




<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 25회차 미션 시작합니다.

03. Firebase 네트워크 - 04-1, 04-2 챕터를 진행합니다.




leaderboard를 구현하려면 역시 Firebase를 설정해 주어야 합니다.

Firebase에는 아직 Unity에 대한 매뉴얼 등의 자료가 충분하지 않아 초반 설정 및 사용에 어려움이 있을 수 있습니다.

Firebase - Database - [Realtime 데이터베이스 만들기]

+ 잠금 모드로 시작 - read / write를 반드시 인증하여야 사용 가능
+ 테스트 모드로 시작 - 누구나 접근이 가능



Realtime 데이터베이스 만들기를 클릭하면 위와 같이 Database가 생성된 것을 확인할 수 있습니다.
현재는 테스트 모드로 진행을 하고 추후 접근 권한 설정은 다시 설명합니다.




이제 Unity에서 Install Package..를 진행하여 dotnet4 - FirebaseDatabase를 설치해줍니다.




FirebaseDatabase의 주요 함수들입니다.

Unity 에디터에서 Database에 접근하여 사용하기 위해 SetEditorDatabaseUrl()을 호출하여 사용하는데 이때 전달해야 하는 URL 정보는 google-services-desktop.json에 저장된 "firebase_url"과 동일해야 합니다.




위와 같이 잘 설정되어 있다면, 해당 URL 정보는 FirebaseApp.DefaultInstance.Options.DatabaseUrl에 저장된다는 내용입니다.



사용되는 함수에 대한 지속 설명입니다.

데이터 읽기, 변경 이벤트 등록, 정렬, 필터링 등 다양한 데이터에 접근하여 사용할 수 있는 함수들이 제공된다고 보면 됩니다.



Unity에서 구현할 Script classes 구조 설명입니다.

+ UserScore - User의 Score값을 저장하는 하나의 단위이며, FirebaseDatabase를 구성하는 한 단위입니다.
+ LeaderboardHandler - topScores 정보를 가지고 있고, Thread based 함수를 사용하여 Firebase Database에 접근합니다.
   또한 Firebase의 Database에는 사용자 순위 정보가 시간순으로 되어 있기 때문에 LeaderboardHandler에서 순위별 정렬 등도 처리합니다.


스크립트들을 구현합니다.

[Serializable]
public class UserScore_New
{
    public static string userIdPath = "user_id";
    public static string userNamePath = "username";
    public static string scorePath = "score";
    public static string timestampPath = "timestamp";
    public static string otherDataPath = "data";
    
    public string userId;
    public string userName;
    public long score;
    public long timestamp;
    public Dictionary<string, object> otherData;
    
    public UserScore_New(string userId, string userName, long score, long timestamp, Dictionary<string, object>otherData = null) {
        this.userId = userId;
        this.userName = userName;
        this.score = score;
        this.timestamp = timestamp;
        this.otherData = otherData;
    }
   
    // Firebase에서 전달받은 Record로 처리
    public UserScore_New(DataSnapshot record) {
        // record는 database 처리의 한 단위
        userId = record.Child(userIdPath).Value.ToString();
        if (record.Child(userNamePath).Exists) userName = record.Child(userNamePath).Value.ToString();
        long score;
        if (Int64.TryParse(record.Child(scorePath).Value.ToString(), out score)) this.score = score;
        else this.score = Int64.MinValue;
        long timestamp;
        if (Int64.TryParse(record.Child(timestampPath).Value.ToString(), out timestamp)) this.timestamp = timestamp;
        if (Int64.TryParse(record.Child(otherDataPath).Exists && record.Child(otherDataPath).HasChildren) {
            this.otherData = new Dictionary<string, object>();
            foreach (var keyValue in record.Child(otherDataPath).Children) {
                otherData[keyValue.Key] = keyValue.Value;
            }
        }
    }
    
    public static UserScore CreateScoreFromRecord(DataSnapshot record) {
        if (record == null) { Debug.LogWarning("Null DataSnapshot record in UserScore.CreateScoreFromRecord"); return null; }
        if (record.Child(userIdPath).Exists && record.Child(scorePath).Exists && record.Child(timestampPath).Exists) {
            return new UserScore(record);
        }
        Debug.ogWarning("Invalid record format in UserScore.CreateScoreFromRecord");
        return null;
    }
    
    // json to dictionary
    public Dictionary<string, object> ToDictionary() {
        return new Dictionary<string, object>() {
            {userIdPath, userId },
            {userNamePath, userName },
            {scorePath, score },
            {timestampPath, timestamp },
            {otherDataPath, otherData }
        };
    }
}




public static class FirebaseInitializer_New
{
    private static List<Action<DependencyStatus>> initializeCallbacks = new List<Action<DependencyStatus>>();
    private static DependencyStatus dependencyStatus;
    
    private static bool initialized = false;
    private static bool fetching = false;
    private static bool activateFetch = false;
    
    public static void Initialize(Action<DependencyStatus> callback) {
        lock (initializeCallbacks) {
            if (initialized) {
                callback(DependencyStatus);
                return;
            }
            
            initializeCallbacks.Add(callback);
            FirebaseApp.CheckAndFixDependenciesAsync().ContinueWith(task => {
                lock (initializeCallbacks) {
                    dependencyStatus = task.Result;
                    initialized = true;
                    CallInitializedCallbacks();
                }
            });
        }
    }
    
    private static void CallInitializedCallbacks() {
        lock (initializeCallbacks) {
            foreach (var callback in initializeCallbacks) {
                callback(dependencyStatus);
            }
            initializeCallbacks.Clear();
        }
    }
}





public class UserScoreArgs: EventArgs
{
    // User 1명에 대한 읽기, 쓰기 이벤트
    public UserScore score;
    public string message;
    public UserScoreArgs(UserScore score, string message) {
        this.score = score;
        this.message = message;
    }
}
public class LeaderboardArgs: EventArgs
{
    // User Score에 대한 읽기, 쓰기 이벤트
    pubic DataTime startDate;
    public DataTime endDate;
    public List<UserScore> scores;
}
public class LeaderboardController : MonoBehaviour
{
    private bool initialized = false;
    private bool readytoInitialize = false;
    private DatabaseReference = databaseRef;
    public string AllscoreDataPath => "all_scores"; // Firebase의 root
    
    pubic event EventHandler OnInitialized;
    
    private bool addingUserScore = false;
    private bool sendAddedScoreEvent = false;
    private UserScoreArgs addedScoreArgs;
    private event EventHandler<UserScoreArgs> OnAddedScore;
    
    privte void Start() {
        FirebaseInitializer_New.Initialize(dependencyStatus => {
            if (dependencyStatus == Firebase.DependencyStatus.Available) {
                readytoInitialize = true;
                InitializeDatabase();
            }
            else
                Debug.LogError("Could not resolve all Firebase dependencies: " + dependencyStatus);
        });
    }
   
    private void InitializeDatabase() {
        if (initialized) return;
        FirebaseApp app = FirebaseApp.DefaultInstance;
        if (app.Options.DatabaseUrl != null)
            app.SetEditorDatabaseUrl(app.Options.DatabaseUrl);
        databaseRef = FirebaseDatabase.DefaultInstance.RootReference;
        initialized = true;
        readytoInitialize = false;
        OnInitialized(this, null);
    }
    
    void Update() {
        if (sendAddedScoreEvent) { // user가 점수 쓰기 완료.
            sendAddedScoreEvent = false;
            OnAddedScore(this, addedScoreArgs);
        }
    }
   
    public Task AddScore(string userId, string userName, int score, long timestamp = 1L, Dictionary<string, object> otherData = null) {
        if (timestamp <= 0) timestamp = DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond; // Unity TactTime -> Unix TactTime
        
        var userScore = new UserScore(userId, userName, score, timestamp, otherData);
        return AddScore(userScore);
    }
   
    pubic Task<UserScore> AddScore(UserScore userScore) {
        if (addingUserScore) { Debug.LogError("Running add user score task!"); return null; }
        
        var scoreDictionary = userScore.ToDictionary();
        addingUserScore = true;
        
        return Task.Run(() => {
            var newEntry = databaseRef.Child(AllscoreDataPath).Push();
            return newEntry.SetValueAsync(scoreDictionary).ContinueWith(task => {
                if (task.Exception != null) { Debug.LogWarning("Exception adding score: " + task.Exception); return null; }
                if (!task.IsCompleted) { return null; }
                addingUserScore = false;
                addedScoreArgs = new UserScoreArgs(userScore, userScore.userId + " Added!");
                sendAddedScoreEvent = true;
                return userScore;
            }).Result;
        });
    }
    
    private bool gettingUserScore = false;
    public int GetUserScore(string userId) {
        gettingUserScore = true;
        databaseRef.Child(AllscoreDataPath)
            .OrderByChild(UserScore.userIdPath)
            .StartAt(userId)
            .EndAt(userId)
            .GetValueAsync().ContinueWith(task => {
                if (task.Exception !=) throw task.Exception;
                if (!task.IsCompleted) return;
            });
    }
}


오늘도 어려운 내용을 후루룩 머리속에 집어넣느라고 굳은 머리가 고생하네요.
이거 초기에 설정잡고 연동하고, 이벤트 처리 로직 등 할 때에는 고생했을 것 같네요 ㅠ.
잘 된 구조를 만들어 주셨으니 잘 가져다 쓰면 되겠습니다. ㅎㅎ





Firebase내 Database에 저장된 데이터의 구조입니다.


다음 시간에 leaderboard 마무리 및 사용자 데이터 처리에 대해 진행합니다.




<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0


 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 100% 환급 챌린지 24회차 미션 시작합니다.

03. Firebase 네트워크 - 02, 03 챕터를 진행합니다.




Firebase 설정하기

1. Firebase에 가입하기

기본 Spark 무료 요금제이며, 실시간 데이터베이스의 사용량이 늘어나면 사용 제한이 걸리는 방식. Blaze 유료 종량제도 10G 까지는 과금되지 않음.




2. 프로젝트 추가하기.

콘솔로 이동 - 사용중인 프로젝트 목록이 나옵니다.

프로젝트를 "FastCampusDev"라는 이름으로 생성합니다. Google 애널리틱스 구성도 사용으로 하고, 다른 것들은 기본으로 하여 생성합니다.





3. 앱 추가하기: Unity -> Android

관리 메인 화면에서 "Unity" 앱 생성을 클릭하여 사용할 앱을 등록합니다.

"[v] Register as Android app"을 선택하고 Android 패키지 이름을 "comfastcampusdev.firebase"라고 작성합니다.
앱 닉네임은 "FastCampus Android Firebase"라고 작성합니다.





4. 구성 파일 다운로드

Firebase 콘솔에서도 다시 다운로드 가능합니다. 다운로드 파일을 Unity의 "Assets"에 적용하라는 안내도 보여주고 있네요.

 


google-services.json 파일을 다운로드 하고 편집기에서 열어서 살펴봅니다.


firebase_url이 중요합니다. firebase_url을 이용하여 database나 cloud storage등을 접근하여 사용하게 됩니다.
storage_bucket은 cloud storage에 파일 접근을 할 수 있게 해주는 주소입니다. 밑에 내용들은 Mobile에서 사용되는 Client 관련 정보라고 보면 됩니다.



해당 파일을 Unity Project의 "Assets"에 추가하여 줍니다.




5. Firebase SDK 다운로드

마찬가지로 Firebase Console에서 언제든 다운로드 가능합니다.




위의 그림과 같이 사용자 인증 설정하기를 진행해 주어야 합니다.

이메일/비밀번호, 전화, Google, Play게임, 게임 센터, Facebook, Twitter, GitHub, Yahoo, Microsoft, Apple, 익명 등 다양한 방법의 로그인을 제공합니다. 대단합니다. 항상 반복적으로 개발해와야 했던 내용이었 것만... 어느 반복이 너무 싫은 천재 개발자의 아이디어에서 시작된 것 같네요..

여기서는 테스트를 위해 "이메일/비밀번호" 방식을 "사용 설정"으로 변경하고 진행합니다.



Unity SDK 설치하기입니다.

Unity 상단 메뉴의 "Assets - Import Package - Custom Package..."를 클릭하여 Import 탐색기 다이알로그가 나타납니다.

여기서 아까 다운받은 firebase_unity_sdk_x.xx.x 폴더로 이동하면 dotnet3, dotnet4 폴더를 볼 수 있고, dotnet4로 진입합니다.

꼭 설치해야 하는 것은 FirebaseAnalytics입니다. 해당 기본 패키지를 설치합니다.

그리고 사용자 인증을 진행할 것이기 때문에 FirebaseAuth 패키지도 설치해줍니다.




사용자 인증 관련 주요 함수들입니다.

Singleton Pattern으로 구현되어 있기 때문에 DefaultInstance를 가져다가 사용하면 됩니다.

StreamingAssets 폴더에 google-services-desktop 파일이 생성되어 있다면 잘 된 것입니다.





Unity에서 사용할 Login UI입니다. 시간 관계상 구성된 상태로 진행합니다.

Email & Password 방식으로 로그인하고, 신규 가입이나 로그아웃 버튼 등이 추가된 상태입니다.


이제 인증을 위한 스크립트를 생성합니다.

public class FirebaseAuthController
{
    // Singletone
    private static FirebaseAuthController instance = null;
    public static FirebaseAuthController Instance {
        get {
            if (instance == null) instance = new FirebaseAuthController();
            return instance;
        }
    }
    
    private FirebaseAuth auth;
    private FirebaseUser user;
    
    private string displayName;
    private string emailAddress;
    private Uri photoUrl;
    
    public string UserId => user?.UserId ?? string.Empty;
    public string DisplayName => displayName;
    public string EmailAddress => emailAddress;
    public Uri PhotoUrl => photoUrl;
    
    public Action<bool> OnChangedLoginState;
    
    public void InitializeFirebase() {
        auth = FirebaseAuth.DefaultInstance;
        auth.StateChanged += OnAuthStateChanged;
        OnAuthStateChanged(this, null);
    }
    
    public void CreateUser(string email, string password) {
        auth.CreateUserWithEmailAndPasswordAsync(email, password).ContinueWith(task => {
            if (task.IsCanceled) { Debug.LogError("CreateUserWithEmailAndPasswordAsync was canceled."); return; }
            if (task.IsFaulted) { // 에러 발생된 경우 처리
                Debug.LogError("CreateUserWithEmailAndPasswordAsync encountered an error: " + task.Exception);
                int errorCode = GetFirebaseErrorCode(task.Exception);
                switch (errorCode) {
                case (int)AuthError.EmailAlreadyInUse:
                    Debug.LogError("Email Already In Use");
                    break;
                case (int)AuthError.InvalidEmail:
                    Debug.LogError("Invalid Email");
                    break;
                case (int)AuthError.WeakPassword:
                    Debug.LogError("Weak Password");
                    break;
                }
                return;
            }
            
            FirebaseUser newUser = task.Result; // 성공 결과
            Debug.LogFormat("Frebase user created successfully: {0} ({1})", newUser.DisplayName, newUser.UserId);
        });
    }
    
    public void SignIn(string email, string password) {
        auth.SignInWithEmailAndPasswordAsync(email, password).ContinueWith(task => {
            if (task.IsCanceled) { Debug.LogError("SignInWithEmailAndPasswordAsync was canceled."); return; }
            if (task.IsFaulted) { // 에러 발생된 경우 처리
                Debug.LogError("SignInWithEmailAndPasswordAsync encountered an error: " + task.Exception);
                int errorCode = GetFirebaseErrorCode(task.Exception);
                switch (errorCode) {
                case (int)AuthError.WrongPassword:
                    Debug.LogError("Wrong Password");
                    break;
                case (int)AuthError.UnverifiedEmail:
                    Debug.LogError("Unverified Email");
                    break;
                case (int)AuthError.InvalidEmail:
                    Debug.LogError("Invalid Email");
                    break;
                }
                return;
            }
            
            FirebaseUser newUser = task.Result; // 성공 결과
            Debug.LogFormat("Frebase user signed in successfully: {0} ({1})", newUser.DisplayName, newUser.UserId);
        });
    }
    
    public void SignOut() {
        auth.SignOut();
    }
    
    // Firebase의 exception id 가져오기
    private int GetFirebaseErrorCode(AggregateException exception) {
        FirebaseException firebaseException = null;
        foreach (Exception e in exception.Flatten().InnerExceptions) {
            firebaseException = e as FirebaseException; // Firebase Exception인 경우만 처리
            if (firebaseException != null) break;
        }
        return firebaseException?.ErrorCode ?? 0;
    }
   
    private void OnAuthStateChanged(object sender, EventArgs eventArgs) {
        if (auth.CurrentUser != user) {
            bool signedIn = (user != auth.CurrentUser && auth.CurrentUser != null);
            if (!signedIn && user != null) {
                Debug.Log("Signed out: " + user.UserId);
                OnChangedLoginState?.Invoke(false); // 로그아웃 이벤트 전달
            }
            
            user = auth.CurrentUser;
            if (signedIn) {
                Debug.Log("Signed in: " + user.UserId);
                displayName = user.DisplayName ?? string.Empty;
                emailAddress = user.Email ?? string.Empty;
                photoUrl = user.PhotoUrl ?? null;
                OnChangedLoginState?.Invoke(true); // 로그인 이벤트 전달
            }
        }
    }
}



public class FirebaseAuthUIController : MonoBehaviour
{
    public TMP_InputField emailInputField;
    public TMP_InputField passwordInputField;
    public TMP_Text outputText;
    
    void Start() {
        FirebaseAuthController.Instance.OnChangedLoginState += OnChangedLoginState;
        FirebaseAuthController.Instance.InitializeFirebase();
    }
    
    public void CreateUser() {
        string email = emailInputField.text;
        string password = passwordInputField.text;
        FirebaseAuthController.Instance.CreateUser(email, password);
    }
    
    public void SignIn() {
        string email = emailInputField.text;
        string password = passwordInputField.text;
        FirebaseAuthController.Instance.SignIn(email, password);
    }
    
    public void SignOut() {
        FirebaseAuthController.Instance.SignOut();
    }
    
    private void OnChangedLoginState(bool signedIn) {
        outputText.text = signedIn ? "Signed in: " : "Signed out: ";
        outputText.text += FirebaseAuthController.Instance.UserId;
    }
}





Unity Canvas에서 위와 같이 설정해 줍니다.




Unity 플레이를 진행하여 시작합니다.

eMail과 Password를 입력하고 "Create User"를 클릭하면 "Signed in: qFNEYxxxxxxxx"라는 사용자 아이디를 볼 수가 있는데, Hash 암호화가 되어 있기 때문에 특수 문자열처럼 표시되었습니다.



Firebase Console로 접근하여 보면 생성된 계정 정보를 확인할 수 있습니다.

그리고 동일 Email 생성 에러 및 암호 에러 등의 테스트를 진행해보면 Firebase를 통한 로그인 시스템이 정상적으로 잘 동작함을 확인할 수 있습니다.

 

회원 가입 및 로그인 등의 시스템이 간단하던 시절에는 개발시 문제가 되지 않았지만 지금처럼 연동 로그인이나 보안 등 복잡한 시스템이 되어가면서는 간단하지 않은 시스템이 되어 고민하게되는 부분이 많게 되는데, 이처럼 자동화되니 참 좋은 세상이네요. 물론 사용량에 따라 돈을 내야할 수 있겠지만 말이지요. ㅎㅎ





<위의 코드들은 제가 보면서 주요한 함수, 코드를 확인하기 위해 타이핑한 용도로, 전체 소스코드가 아님에 주의해 주세요. 전체 코드는 교육 수강을 하면 완벽하게 받으실 수가 있답니다 ^^>

패스트캠퍼스 - 올인원 패키지 : 유니티 포트폴리오 완성 bit.ly/2R561g0

 

유니티 게임 포트폴리오 완성 올인원 패키지 Online. | 패스트캠퍼스

게임 콘텐츠 프로그래머로 취업하고 싶다면, 포트폴리오 완성은 필수! '디아블로'와 '배틀그라운드' 게임을 따라 만들어 보며, 프로그래머 면접에 나오는 핵심 개념까지 모두 잡아 보세요!

www.fastcampus.co.kr

 

+ Recent posts