[패스트캠퍼스 수강 후기] 올인원 패키지 : 유니티 포트폴리오 완성 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

 

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

02. 디아블로 게임 - 41 챕터를 마무리하고,
03. Firebase 네트워크 - 01 챕터를 새롭게 시작합니다.





디아블로 게임의 마지막입니다. 문과 함정을 구현합니다.
스크립터블을 사용한 이벤트 오브젝트를 설명하기 위해 조금 복잡하더라도 이 구조를 선택하였습니다.

DoorTriggerArea는 문 근처 영역에 있을 때 트리거를 발생시킵니다.
DoorController는 문 애니메이션을 보여주는 GameObject입니다.



public class DoorController_New : MonoBehaviour
{
    public DoorEventObject_New doorEventObject;
    public int id = 0;
    public float openOffset = 4f; // 문이 열렸을 때 위로 올라가는 위치
    public float closeOffset = 1f;
    
    private void OnEnable() {
        doorEventObject.OnOpenDoor += OnOpenDoor;
        doorEventObject.OnCloseDoor += OnCloseDoor;
    }
    
    private void OnDisable() {
        doorEventObject.OnOpenDoor -= OnOpenDoor;
        doorEventObject.OnCloseDoor -= OnCloseDoor;
    }
    
    public void OnOpenDoor(int id) {
        if (id != this.id) return;
        StopAllCoroutines();
        StartCoroutine(OpenDoor());
    }
    
    public void OnOpenDoor(int id) {
        if (id != this.id) return;
        StopAllCoroutines();
        StartCoroutine(CloseDoor());
    }
    
    public IEnumerator OpenDoor() { // 애니메이션
        while (transform.position.y < openOffset) {
            Vector3 calcPosition = transform.position;
            calcPosition.y += 0.01;
            transform.position = calcPosition;
            yield return null;
        }
    }
    
    public IEnumerator CloseDoor() {
        while (transform.position.y < closeOffset) {
            Vector3 calcPosition = transform.position;
            calcPosition.y -= 0.01;
            transform.position = calcPosition;
            yield return null;
        }
    }
}




[CreateAssetMenu(fileName = "Event System", menuName = "Event System/Door Event Object_New")]
public class DoorEventObject_New : ScriptableObject
{
    [NonSerialized]
    public Action<int> OnOpenDoor;
    [NonSerialized]
    public Action<int> OnCloseDoor;
    
    public void OpenDoor(int id) {
        OnOpenDoor?.Invoke();
    }
    
    public void CloseDoor(int id) {
        OnCloseDoor?.Invoke();
    }
}




public class DoorTriggerArea_New : MonoBehaviour
{
    public DoorEventObject_New doorEventObject;
    public DoorController_New doorController;
    public bool autoClose = true;
    
    private void OnTriggerEnter(Collider other) {
        doorEventObject.OpenDoor(doorController.id);
    }
    
    private void OnTriggerExit(Collider other) {
        doorEventObject.CloseDoor(doorController.id);
    }
}





주황색 박스를 문이라고 설정한 상태입니다. 내부 Mesh Obstacle로 지정. DoorController_New를 추가.




유니티를 플레이하여 2개의 박스가 문처럼 잘 동작하는 것을 확인할 수 있으며,
AutoClose를 false로 하여 테스트하면 문이 열리고 다시 닫히지 않는 오브젝트로 잘 동작하는 것을 확인할 수 있습니다.



함정은 해당 GameObject가 Collider가 발생하였을 때 Character에 Damage를 발생시키는 간단한 로직입니다.


public class TrapController_New : MonoBehaviour
{
    public float damageInterval = 0.5f; // 몇초간격 반복 데미지 interval 시간
    public float damageDuration = 5f; // 몇초간 대기
    public int damage = 5;
    private float calcDuration = 0.0f;
    
    [SerializeField]
    private ParticleSystem effect;
    private IDamagable damagable;
    
    private void Update() {
        if (damagable != null) calcDuration -= Time.deltaTime;
    }
    
    private void OnTriggerEnter(Collider other) {
        damagable = other.GetComponent<IDamagable>();
        if (damagable != null) {
            calcDuration = damageDuration;
            effect.Play();
            StartCoroutine(ProcessDamage();
        }
    }
    
    private void OnTriggerExit(Collider other) {
        damagable = null;
        StopAllCoroutines();
        effect.Stop();
    }
    
    IEnumerator ProcessDamage() {
        while (calcDuration > 0 && damagable != null) {
            damagable.TakeDamage(damage, null);
            yield return new WaitForSeconds(damageInterval);
        }
        
        damagable = null;
        effect.Stop();
    }
}




함정에 대한 Unity 설정입니다.





플레이를 해봅니다. 초록색 박스 Collider에 진입하면 1초마다 데미지를 입게 됩니다. 5초후 꺼지고 다시 데미지를 발생합니다.


여기까지 RPG 액션 게임을 구현하는 방법에 대한 중요한 시스템에 대한 설명이 완료되었습니다.
강의를 위한 자료 특히 설명해야할 것이 많은 자료는 정리하여 준비하기가 정말 힘든데.. 잘 정리해 주셨네요.
자료 준비하느라 힘드셨을 듯합니다. 감사합니다.










이제 Firebase에 대한 강의를 시작합니다.
Firebase backend system과 NoSQL 등 실제 게임 제작시에 어떻게 구성하고 활용하는지 교육합니다.
김영민 강사님이 지속 알려주시네요 ^^~





+ Firebase 소개: Firebase에 대해 설명을 하고, 특히 게임 내에서 사용되는 부분을 집중하여 안내할 예정.
+ Firebase 설정
+ 사용자 인증 구현
+ 사용자 순위(leaderboard) 구현 - Real-time 게임 순위
+ 사용자 데이터 저장/불러오기



 

 


Firebase는 BaaS를 제공하는 스타트업 회사로 2014년도에 구글에 인수되었다고 합니다. 와우.. 대단합니다.
게다가 Twitter의 Febric이라는 BaaS 시스템도 구글에 인수되어 Firebase에 통합되고 있다고 합니다..

서버 개발이 필요없다니(???) 와우.. 이..럴..수..가..있..나..?
사용하는 방법을 진짜 봐야겠네요.. Firebase 설정만 하면 된다는 것 같은데 말이죠.. 획기적이네요.
C++ API도 제공된다고 합니다.

단점도 알아봐야겠지요? 유니티에서 게임을 개발하는 단계에서의 단점은..
Desktop 기반의 플랫폼을 지원하지 않습니다. 음.. 저에게는 아쉽네요. 현재 Desktop 기반에서 Unity를 활용하는 것에 초점을 맞추어 보려 하고 있는데 말입니다. Unity 게임 개발 중간에 SDK를 활용해서 테스트를 할 수는 있지만 Desktop이나 MacOS용 게임을 배포하려고 하면 지원이 되지 않는 것입니다.
이를 해결하기 위한 방법으로 중간 웹서비스에 대한 API를 거쳐서 활용하는 방법이 있다고 합니다. 추후 알아봐야겠습니다.

두번째 주의점으로는 유저가 기하급수적으로 늘어나는 경우 자체 구축 서버 시스템보다 유지보수 비용이 많이 증가할 수 있는 단점이 있습니다.

하지만 Firebase를 통해서 BaaS에 대한 기본 개념을 알아보기 쉽고, 중소기업이하 소규모 개발사에서는 기간, 비용 등이 단축될 수 있기 때문에 적극 추천하는 바입니다.




+ Authentication: 기본적으로 구글 연동 로그인 시스템 등을 지원합니다.
+ Database: Realtime과 Cloud 모두 NoSQL기반. Realtime Database는 JSON 구조 저장. Cloud는 데이터를 문서 컬렉션 단위로 저장.
  Cloud의 장점은 복잡한 계층적인 데이터나 추출 쿼리의 복잡성도 해결가능함.

  대신 단순 구조 시스템은 Realtime이 빠름.
+ Cloud Functions: Javascript, Typescript 등의 처리.
+ Hosting: 웹호스팅 서비스. 인앱결제 등. 턴-방식 게임에서도 사용 가능.

+ Google Analytics: 접속 정보 통계 분석. 인앱결제 통계. 특정 스테이지 분석 등.
+ Crashlytics: 앱의 Crash 지점 분석.
+ Cloud Messaging: Push 알림
+ Remote Config:

여기까지 Firebase의 많은 기능들중 게임 개발시 도움이 될만한 내용에 대한 정리입니다.






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

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

 

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

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

www.fastcampus.co.kr

 

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

02 디아블로 게임 - 39. 40 챕터를 진행합니다.

 


NPC와의 대화를 위한 Dialog System 구현입니다.


* Dialogue -> DialogManager에게 내용 전달하면 -> DialogUI에 표시
+ Name - 대화 이름
+ Sentences - 대화 내용


[Serializable]
public clas Dialogue_New
{
    public string name; // NPC 이름
    
    [TextArea(3, 10)]
    public string[] sentences; // 대화문장 배열
}



public class DialogueManager_New : MonoBehaviour
{
    private static DialogueManager_New instance; // Singleton Pattern
    public static DialogManager_New Instance = >instance;
    public Text nameText;
    public Text dialogueText;
    public Animator animator = null;
    private Queue<string> sentences;
    public event Action OnStartDialogue; // 다이알로그 시작 이벤트
    public event Action OnEndDialogue; // 다이알로그 종료 이벤트
    
    private void Awake() {
        instance = this;
    }
    
    void Start() {
        sentences = new Queue<string>();
    }
    
    public void StartDialogue(Dialogue_New dialogue) {
        OnStartDialogue?.Invoke();
        animator?.SetBool("IsOpen", true);
        nameText.text = dialogue.name;
        sentences.Clear();
        foreach (string sentence in dialogue.sentences) {
            sentences.Enqueue(sentence);
        }
        DisplayNextSentence();
    }
    
    public void DisplayNextSentence() {
        if (sentences.Count == 0) { EndDialogue(); return; }
        string sentence = sentences.Dequeue();
        StopAllCoroutines();
        StartCoroutine(TypeSentence(sentence));
    }
    
    public IEnumerator TypeSentence(string sentence) {
        dialogueText.text = string.Empty;
        yield return new WaitForSeconds(0.25f); // 애니메이션 완료 대기
        foreach (char letter in sentence.ToCharArray()) {
            dialogueText.text += letter; // 하나하나 문자로 찍히는 효과
            yield return null;
        }
    }
    
    pubic void EndDialogue() {
        animator?.SetBool("IsOpen", false);
        OnEndDialogue?.Invoke();
    }
}


 



자료 구조인 Queue와 Stack에 대한 설명도 간단히 해주십니다.
Queue : First Input First Out -> 먼저 넣은 것부터 뽑아서 사용.
Stack : First Input Last Out -> 먼저 넣은 것을 제일 마지막에 사용.

 

 

 


해당 대화 구현을 위한 DialogBox GameObject Unity 구성 화면입니다.

Continue 버튼으로 다음 문장을 진행. DisplayNextSentence()를 호출합니다.


이를 사용하는 DialogueNPC를 구현합니다.

public class DialogueNPC_New : MonoBehaviour, IInteractable
{
    [Serializable]
    Dialogue dialogue;
    
    bool isStartDialogue = false;
    GameObject interactGO;
    
    [SerializeField]
    float distance = 2.0f;
    public float Distance => distance;
    
    public void Interact(GameObject other) {
        float calcDistance = Vector3.Distance(other.transform.position, transform.position);
        if (calcDistance > distance) return;
        if (isStartDialogue) return;
        interactGO = other;
        DialogueManager_New.Instance.OnEndDialogue += OnEndDialogue;
        isStartDialogue = true;
        
        DialogueManager_New.Instance.StartDialogue(dialogue);
    }
    
    public void StopInteract(GameObject other) {
        isStartDialogue = false;
    }
    
    private void OnEndDialogue() {
        StopInteract(interactGO);
    }
}


 



Unity에서의 NPC 구성 화면입니다.
Sentences 3개를 넣어서 동작 테스트를 해보도록 합니다.





대화상대를 우클릭하면 대화 상대에게 이동을 하고 distance 거리내로 다가가게 되면 StartDialogue()를 시작으로 첫번째 문장이 표시됩니다.
[Continue] 버튼을 클릭하면 다음 문장을 보여주고 마지막 문장이 완료되면 EndDialogue()로 사라지는 애니메이션이 구동되어 없어집니다.




이제 Quest System을 구현해 보도록 하겠습니다.

QuestObject는 ScriptableObject를 상속받아 구현합니다.

QuestStatus
+ None: X
+ Accepted: 퀘스트 수락
+ Completed: 퀘스트 완료
+ Rewarded: 퀘스트 보상 완료



public enum QuestType_New {
    DestroyEnemy,
    AcquireItem,
}
[Serializable]
public class Quest_New
{
    public int id;
    public QuestType_New type;
    public int targetID; // 적의 정보
    public int count; // 적의 수
    public int completedCount;
    pubic int rewardExp;
    public int rewardGold;
    public int rewardItemId;
    public string title;
    public string description;
}



pubic enum QuestStatus {
    None,
    Accepted,
    Completed,
    Rewarded,
}
[CreateAssetMenu(filename = "New Quest", menuName = "Quest System/Quests/New Quest_New")]
public class QuestObject_New : ScriptableObject
{
    public Quest_New data = new Quest_New();
    public QuestStatus_New status;
}



[CreateAssetMenu(fileName = "Quest Database", menuName = "Quest System/Quests/New Quest Database")]
public class QuestDatabaseObject_New : ScriptableObject
{
    public QuestObject_New[] questObjects;
    
    public void OnValidate() {
        for (int index = 0; index < questObjects.Length; index++) {
            questObjects[index].data.id = index;
        }
    }
}






Quest를 2가지 추가하였고, 2가지 퀘스트를 구성하였습니다.
첫번째는 적을 잡아와라, 두번째는 특정 아이템을 구해오라는 퀘스트로 구성하였습니다.



public class QuestManager_New : MonoBehaviour
{
    private static QuestManager_New instance;
    public static QuestManager_New Instance => instance;
    public QuestDatabaseObject_New questDatabase;
    public event Action<QuestObject_New> OnCompletedQuest;
    
    private void Awake() {
        instance = this;
    }
   
    public void ProcessQuest(QuestType_New type, int targetId) {
        foreach (QuestObject_New questObject in questDatabase.questObjects) {
            if (questObject.status == QuestStatus.Accepted && questObject.data.type == type &&

                questObject.data.targetId == targetId) {
                questObject.data.completedCount++;
                if (questObject.data.completedCount >= questObject.data.count) {
                    questObject.status = QuestStatus.Completed;
                    OnCompletedQuest?.Invoke(questObject);
                }
            }
        }
    }
}



public class QuestNPC_New : MonoBehaviour, IInteractable
{
    public QuestObject questObject;
    public Dialogue readyDialogue; // 퀘스트 준비
    public Dialogue acceptedDialogue; // 이미 수락된 경우
    public Dialogue completedDialogue; // 완료된 퀘스트
    bool isStartQuestDialogue = false;
    GameObject interactGO = null;
    
    void Start() {
        QuestManager_New.Instance.OnCompletedQuest += OnCompletedQuest;
    }
    
    
    // IInteractable 관련 구현은 DialogueNPC_New와 거의 유사하므로 복사하여 수정.
}


그리고 EnemyController_Range에서 TakeDamage()가 발생할 때 적이 죽는 타이밍 즉, IsAlive가 아닌 경우가 발생할 때
QuestManager.Instance.ProcessQuest(QuestType.DestroyEnemy, 0);
를 호출하여 Quest를 확인하도록 처리합니다.

마찬가지로 InventoryObject에서 AddItem()이 발생할 때 관련 퀘스트를 확인하기 위하여
QuestManager.Instance.ProcessQuest(QuestType.AcquireItem, 1);
를 호출하여 Quest를 확인하도록 처리합니다.


 



Unity에서 QuestNPC를 2개 구성하였습니다.
하나는 적을 죽일 때의 Quest이고, 하나는 아이템을 얻는 Quest입니다.




실행을 하면 적을 죽이는 퀘스트와, 아이템을 얻어야 완료되는 2가지 퀘스트가 잘 구동되는 것을 확인할 수 있습니다.

실제로는 게임을 제작할 때 기획 의도에 맞게 Quest를 구성하면 되겠지요.







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

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




 

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

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

www.fastcampus.co.kr

 

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

02 디아블로 게임 - 37. 38 챕터를 진행합니다.




이번 시간은 RPG 게임 플레이 시스템 구현하기 입니다.




+ 플레이어 속성 - 아이템 착용 상태에 따른 속성 변경
+ 플레이어 UI
+ 다이얼로그 시스템 구현 - 대화 시스템
+ 퀘스트 시스템 구현
+ 환경 장치(함정/문) 구현 - 레벨에 따른..





중요한 부분은 Attribute와 ModifiableInt 클래스 입니다.

+ Attribute - ModifiableInt를 가지고 Value를 설정
+ ModifiableInt - Base값을 가지고 변경되어진 값을 사용하는 Value(?)
+ AttributeType - 속성의 Type 설정
+ StatsObject - Attribute를 설정

이제 스크립트를 구현합니다. 바로 로직구현으로 고고고~~ ㅎㅎ


[Serializable]
public class ModifiableInt_New
{
    [NonSerialized]
    private int baseValue;
    [SerializeField]
    private int modifiedValue;
    
    public int BaseValue {
        get => baseValue;
        set {
            baseValue = value;
            UpdateModifiedValue();
        }
    }
    
    public int ModifiedValue {
        get => modifiedValue;
        set => modifiedValue = value;
    }
    
    // 변경되었다는 이벤트를 받기 위한 처리
    private event Action<ModifiableInt_New> OnModifiedValue;
    
    private List<IModifier> modifiers = new List<IModifier>();
    
    public ModifiableInt_New(Action<ModifiableInt_New> method = null) {
        ModifiedValue = baseValue;
        RegisterModEvent(method);
    }
    
    public void RegisterModEvent(Action<ModifiableInt_New> method) {
        if (method != null) {
            OnModifiedValue += method;
        }
    }
    
    public void UnregisterModEvent(Action<ModifiableInt_New> method) {
        if (method != null) {
            OnModifiedValue -= method;
        }
    }
    
    private void UpdateModifiedValue() {
        int valueToAdd = 0;
        foreach (IModifier modifier in modifiers) {
            modifier.AddValue(ref valueToAdd);
        }
        
        ModifiedValue = baseValue + valueToAdd;
        
        OnModifiedValue?.Invoke(this);
    }
    
    public void AddModifier(IModifier modifier) {
        modifiers.Add(modifier);
        UpdateModifiedValue();
    }
    
    public void RemoveModifier(IModifier modifier) {
        modifiers.Remove(modifier);
        UpdateModifiedValue();
    }
}




[Serializable]
public class Attribute_New
{
    public AttributeType type;
    public ModifiableInt_New value;
}



[CreateAssetMenu(fileName = "New Stats", menuName = "Stats System/New Character Stats New")[
public class StatsObject_New : ScriptableObject
{
    public Attribute_New[] attributes;
    public int level;
    public int exp;
    public int Health { get; set; } // Property로 만들면 Serializable 되지 않는다.
    public int Mana { get; set; }
    
    public float HealthPercentage {
        get {
            int health = Health;
            int maxHealth = Health;
            foreach (Attribute_New attribute in attributes) {
                if (attribute.type == AttributeType.Health) {
                    maxHealth = attribute.value.ModifiedValue;
                }
            }
            
            return (maxHealth > 0 ? ((float)health / (float)maxHealth) : 0f);
        }
    }
   
    public float ManaPercentage {
        get {
            int mana = Mana;
            int maxMana = Mana;
            foreach (Attribute_New attribute in attributes) {
                if (attribute.type == AttributeType.Mana) {
                    maxMana = attribute.value.ModifiedValue;
                }
            }
            
            return (maxMana > 0 ? ((float)mana / (float)maxMana) : 0f);
        }
    }
   
    public event Action<StatsObject> OnChangedStats;
    
    [NonSerialized]
    private bool isInitialize = false;
    public void OnEnable() {
        InitializeAttribute();
    }
    
    public void InitializeAttribute() {
        if (isInitialize) return;
        isInitialize = true;
        foreach (Attribute_New attribute in attributes) {
            attribute.value = new ModifiableInt_New(OnModifiedValue);
        }
        level = 1;
        exp = 0;
        SetBaseValue(AttributeType.Agility, 100);
        SetBaseValue(AttributeType.Intellect, 100);
        SetBaseValue(AttributeType.Stamina, 100);
        SetBaseValue(AttributeType.Strength, 100);
        SetBaseValue(AttributeType.Health, 100);
        SetBaseValue(AttributeType.Mana, 100);
        
        Health = GetModifiedValue(AttributeType.Health);
        Mana = GetModifiedValue(AttributeType.Mana);
    }
    
    private void OnModifiedValue(ModifiableInt_New value) {
        OnChangedStats?.Invoke(this);
    }
    
    public int GetBaseValue(AttributeType type) {
        foreach (Attribute attribute in attributes) {
            if (attribute.type == type) {
                return attribute.value.BaseValue;
            }
        }
        return -1;
    }
    
    public void SetBaseValue(AttributeType type, int value) {
        foreach (Attribute attribute in attributes) {
            if (attribute.type == type) {
                attribute.value.BaseValue = value;
            }
        }
    }
    
    public int GetModifiedValue(AttributeType type) {
        foreach (Attribute attribute in attributes) {
            if (attribute.type == type) {
                return attribute.value.ModifiedValue;
            }
        }
        return -1;
    }
    
    public int AddHealth(int value) {
        Health += value;
        OnChangedStats?.Invoke(this);
        return Health;
    }
    
    public int AddMana(int value) {
        Mana += value;
        OnChangedStats?.Invoke(this);
        return Mana;
    }
}






이제 Unity Editor에서 PlayerStats를 추가하여 위와 같이 설정해 줍니다.

위와 관련 기존 코드에서 수정되는 부분들만 작성합니다.

public class PlayerCharacter
{
    [SerializeField]
    public StatsObject playerStats;
    
    public void OnEnterAttackState() {
        UnityEngine.Debug.Log("OnEnterAttackState()");
        playerStats.AddMana(-3); // 임시 테스트값
    }
    
    public void OnExitAttackState() {
        UnityEngine.Debug.Log("OnExitAttackState()");
    }
    
    public bool IsAlive => playerStats.Health > 0;
    
    public void TakeDamage(int damage, GameObject damageEffectPrefab) {
        //..
        playerStats.AddHealth(-damage);
        //..
    }
    
    private void OnUseItem(ItemObject itemObject) {
        foreach (ItemBuff buff in itemObject.data.buffs) {
            if (buff.stat == AttributeType.Health) {
                playerStats.AddHealth(buff.value);
            }
        }
    }
}


 



Unity에서 플레이어의 속성 UI를 표시하기 위해 무료 Asset을 받아서 Canvas에 위와 같이 설정합니다.


public class PlayerInGameUI_New : MonoBehaviour
{
    public StatsObject_New playerStats;
    public Text levelText;
    public Image healthSlider;
    public Image manaSlider;
    
    void Start() {
        levelText.text = playerStats.level.ToString("n0");
        healthSlider.fillAmount = playerStats.HealthPercentage;
        manaSlider.fillAmount = playerStats.ManaPercentage;
    }
    
    private void OnEnable() {
        playerStats.OnChangedStats += OnChangedStats;
    }
    
    private void OnDisable() {
        playerStats.OnChangedStats -= OnChangedStats;
    }
    
    private void OnChangedStats(StatsObject_New statsObject) {
        levelText.text = playerStats.level.ToString("n0");
        healthSlider.fillAmount = playerStats.HealthPercentage;
        manaSlider.fillAmount = playerStats.ManaPercentage;
    }
}





플레이 화면입니다.
공격을 당하면 HP가 줄어들고, 공격을 하면 Mana가 줄어들고, 물약을 먹으면 HP가 다시 채워지는 것을 확인할 수 있습니다.






이제 위와 같은 속성을 표시하기 위한 구현입니다.

public class PlayerStatsUI_New : MonoBehaviour
{
    public InventoryObject equipment;
    public StatsObject playerStats;
    public Text[] attributeTexts;
    
    private void OnEnable() {
        playerStats.OnChangedStats += OnChangedStats;
        if (equipment != null && playerStats != null) {
            foreach (InventorySlot slot in equipment.Slots) {
                slot.OnPreUpdate += OnRemoveItem;
                slot.OnPostUpdate += OnEquipItem;
            }
            UpdateAttributeTexts();
        }
    }
    
    private void OnDisable() {
        playerStats.OnChangedStats -= OnChangedStats;
        if (equipment != null && playerStats != null) {
            foreach (InventorySlot slot in equipment.Slots) {
                slot.OnPreUpdate -= OnRemoveItem;
                slot.OnPostUpdate -= OnEquipItem;
            }
            UpdateAttributeTexts();
        }
    }
    
    private void UpdateAttributeTexts() {
        attributeTexts[0].text = playerStats.GetModifiedValue(AttributeType.Agility).ToString("n0");
        attributeTexts[1].text = playerStats.GetModifiedValue(AttributeType.Intellect).ToString("n0");
        attributeTexts[2].text = playerStats.GetModifiedValue(AttributeType.Stamina).ToString("n0");
        attributeTexts[3].text = playerStats.GetModifiedValue(AttributeType.Strength).ToString("n0");
    }
    
    private void OnRemoveItem(InventorySlot slot) {
        if (slot.ItemObject == null) return;
        foreach (ItemBuff buff in slot.item.buffs) {
            foreach (Attribute attribute in playerStats.attributes) {
                if (attribute.type == buff.stat) {
                    attribute.value.RemoveModifier(buff);
                }
            }
        }
    }
    
    private void OnEquipItem(InventorySlot slot) {
        if (slot.ItemObject == null) return;
        foreach (ItemBuff buff in slot.item.buffs) {
            foreach (Attribute attribute in playerStats.attributes) {
                if (attribute.type == buff.stat) {
                    attribute.value.AddModifier(buff);
                }
            }
        }
    }
    
    private void OnChangedStats(StatsObject statsObject) {
        UpdateAttributeTexts();
    }
}





여기까지 작성하고 플레이를 시작하여 장착 테스트를 해봅니다.
모든 값들이 잘 표시됨을 확인할 수 있습니다. @~@;;;;





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

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


 

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

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

www.fastcampus.co.kr

 

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

02 디아블로 게임 - 35. 36 챕터를 진행합니다.




ItemInstances는 기능에 대한 오브젝트는 아니고, 아이템들에 대한 GameObject List를 가진 클래스라고 보면 됩니다.

public class ItemInstances_New
{
    public List<Transform> itemTransforms = new List<Transform>();
    
    public void OnDestroy() {
        // 하위 아이템들도 모두 삭제
        foreach (Transform item in itemTransforms) {
            Destroy(item.gameObject);
        }
    }
}




public class PlayerEquipment_New : MonoBehaviour
{
    public InventoryObject equipment;
    private EquipmentCombiner_New combiner;
    private ItemInstances_New[] itemInstances = new ItemInstances_New[8];
    public ItemObject[] defaultItemObjects = new ItemObject[8];
    
    private void Awake() {
        combiner = new EquipmentCombiner_New(gameObject);
        
        for (int i = 0; i < equipment.Slots.Length; i++) {
            equipment.Slots[i].OnPreUpdate += OnRemoveItem;
            equipment.Slots[i].OnPostUpdate += OnEquipItem;
        }
    }
    
    void Start() {
        foreach (InventorySlot slot in equipment.Slots) { // 기본 장착
            OnEquipItem(slot);
        }
    }
    
    private void OnEquipItem(InventorySlot slot) {
        ItemObject itemObject = slot.ItemObject;
        if (itemObject == null) { // 장비 삭제
            EquipDefaultItemBy(slot.AllowedItems[0]);
            return;
        }
       
        int index = (int)slot.AllowedItems[0];
        
        switch (slot.AllowedItems[0]) {
            case ItemType.Helmet:
            case ItemType.Chest:
            case ItemType.Pants:
            case ItemType.Boots:
            case ItemType.Gloves:
                itemInstances[index] = EquipSkinnedItem(itemObject);
                break;
            case ItemType.Pauldrons:
            case ItemType.LeftWeapon:
            case ItemType.RightWeapon:
                itemInstances[index] = EquipMeshItem(itemObject);
                break;
        }
    }
   
    // Skinned Item 장착
    private ItemInstances_New EquipSkinnedItem(ItemObject itemObject) {
        if (itemObject == null) return null;
        Transform itemTransform = combiner.AddLimb(itemObject.modelPrefab, itemObject.bonNames);
        
        if (itemTransform != null) {
            ItemInstances_New instance = new ItemInstances_New();
            instance.itemTransform.Add(itemTransform);
            return instance;
        }
        return null;
    }
    
    // Static Item 장착
    private ItemInstances_New EquipMeshItem(ItemObject itemObject) {
        if (itemObject == null) return null;
        Transform[] itemTransforms = combiner.AddMesh(itemObject.modelPrefab);
        
        if (itemTransform.Length > 0) {
            ItemInstances_New instance = new ItemInstances_New();
            instance.itemTransform.AddRange(itemTransform.ToList<Transform>());
            return instance;
        }
        return null;
    }
    
    private void EquipDefaultItemBy(ItemType type) {
        int index = (int)type;
        
        ItemObject itemObject = defaultItemObjects[index];
        switch (type) {
            case ItemType.Helmet: 
            case ItemType.Chest: 
            case ItemType.Pants: 
            case ItemType.Boots: 
            case ItemType.Gloves: 
                itemInstances[index] = EquipSkinnedItem(itemObject); 
                break; 
            case ItemType.Pauldrons: 
            case ItemType.LeftWeapon: 
            case ItemType.RightWeapon: 
                itemInstances[index] = EquipMeshItem(itemObject); 
                break; 
        } 
    } 
    
    private void OnDestroy() {
        foreach (ItemInstances_New item in itemInstances) {
            item.Destroy();
        }
    }
    
    private void OnRemoveItem(InventorySlot slot) {
        ItemObject itemObject = slot.ItemObject;
        if (itemObject == null) {
            RemoveItemBy(slot.AllowedItems[0]);
            return;
        }
        
        if (slot.ItemObject.modelPrefab != null) {
            RemoveItemBy(slot.AllowedItems[0]);
        }
    }
    
    private void RemoveItemBy(ItemType type) { // 특정 위치 아이템 삭제
        int index = (int)type;
        if (itemInstances[index] != null) {
            itemInstances[index].Destroy();
            itemInstances[index] = null;
        }
    }
}





위와 같이 보이지는 않지만 Bone 정보가 있고, 여기에 아이템의 Skinned Mesh를 잘 입혀서 보여주는 것이 아이템 장착이라고 해석하면 될 것 같네요.




Default Equipment를 설정합니다.




유니티 플레이를 해보면, 기본 장착 아이템들을 가지고 있는 캐릭터를 확인할 수 있습니다.
인벤토리에서 아이템을 드래그하여 장착하면 UI 상의 캐릭터가 아이템을 장착하는 것을 확인할 수 있고,
장비 인벤토리에서 아이템을 바닥에 드래그하여 버리게 되면, 다시 Default Item들을 가진 캐릭터로 설정됨을 확인할 수 있습니다.

아이템들에 대한 Mesh 형태에 따라 처리 방법이 달라지기 때문에 기획자, 디자이너와 어떤 형태로 만들고 구동할 것인지를 협의하고 그에 맞게 코드를 수정하여 만들어주면 됩니다.





 

 



이제 해당 아이템들을 땅에서 획득하고, 물약 등을 사용하는 루틴을 구현해 보겠습니다.


public class GroundItem_New : MonoBehaviour
{
    public ItemObject itemObject;
    
    private void OnValidate() {
#if UNITY_EDITOR
        GetComponent<SpriteRenderer>().sprite = itemObject?.icon;
#endif
    }
}


public class PlayerCharacter
{
    private void OnTriggerEnter(Collider other) {
        var item = other.GetComponent<GroundItem_New>();
        if (item) {
            if (inventory.AddItem(new Item(item.itemObject), 1)) // 인벤토리에 아이템을 추가하고
                Destroy(other.gameObject); // 땅의 아이템은 삭제.
        }
    }
}


 



이렇게 구현된 상태로 플레이를 해보면 바닥의 투구 아이템을 지나갈 때 캐릭터와 부딪히게 되고, 캐릭터는 아이템을 획득하게 됩니다.




이제 근처에 있는 아이템을 클릭하여 획득하는 루틴을 구현해 보겠습니다.

public interface IInteractable_New // 상자나 다른 다른 오브젝트에도 사용 가능하기에 별도 interface로 구현
{
    float Distance { get; }
    bool Interact(GameObject other);
    void StopInteract(GameObject other);
}


public class PickupItem_New : MonoBehaviour, IInteractable_New
{
    public float distance = 3.0f; // 아이템과 특정 거리 내에서만 픽업 가능하도록
    public float Distance => distance;
    
    public ItemObject itemObject;
    
    public bool Interact(GameObject other) {
        float calcDistance = Vector3.Distance(transform.position, other.transform.position);
        if (calcDistance > distance) return false;
        
        return other.GetComponent<PlayerCharacter_New>()?.PickupItem(this) ?? false;
    }
    
    public void StopInteract(GameObject other) {
    }
    
    private void OnValidate() {
#if UNITY_EDITOR
        GetComponent<SpriteRenderer>().sprite = itemObject?.icon;
#endif
    }
    
    private void OnDrawGizmosSelected() {
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(transform.position, distance);
    }
}

public class PlayerCharacter_New에서 우클릭할 때의 처리도 추가해 줍니다.
아이템을 우클릭하면 캐릭터는 해당 위치로 이동을 하다가 설정된 거리 내에 아이템이 있다면 Interact가 처리되도록 하여 아이템을 획득하는 루틴을 처리하면 됩니다.




이렇게 처리를 하고 플레이를 해보면 아이템을 지나가도 자동으로 획득이 되지 않지만, 먼 거리에서 아이템을 우클릭하고 이동하면 근처에 도달하여 아이템을 획득하는 장면을 볼 수 있습니다.





물약과 같은 아이템을 사용하는 루틴입니다. 기존 소스 코드에서 추가된 주요 함수들만 작성하였습니다.

public class DynamicInventoryUI
{
    protected override void OnRightClick(InventorySlot slot) {
        inventoryObject.UseItem(slot);
    }
}


public class InventoryObject : ScriptableObject
{
    public void UseItem(InventorySlot slotToUse) {
        if (slotToUse.ItemObject == null || slotToUse.item.id < 0 || slotToUse.amount <= 0) return;
        
        ItemObject itemObject = slotToUse.ItemObject;
        slotToUse.UpdateSlot(slotToUse.item, slotToUse.amount - 1);
        
        OnUseItem.Invoke(itemObject);
    }
}


public class PlayerCharacter
{
    private void OnUseItem(ItemObject itemObject) {
        foreach (ItemBuff buff in itemObject.data.buffs) {
            if (buff.stat == CharacterAttribute.Health)
                this.health += buff.value;
        }
    }
}


중요한 시스템들에 대한 구현이 많았어서 어렵기도 하고 복잡하기도 하고.. 그러네요 ^^
하지만 그만큼 중요한 내용이고, 그래도 어려운 내용을 이렇게 하나씩 구현해 가며 차근히 설명해주시니 너무 감격스럽지 않을 수 있겠습니까.



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

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






 

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

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

www.fastcampus.co.kr

 

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

02 디아블로 게임 - 33. 34 챕터를 진행합니다.




PlayerInventory를 만들어서 등록 가능한 아이템들의 데이터 정보를 확인할 수 있습니다. 아직 등록된 아이템이 없기 때문에 내용은 없고 id도 -1인 상태로 등록된 아이템이 없음을 나타냅니다.




인벤토리에 사용하는 이미지들도 위와 같이 등록하여 설정해 줍니다.





현재까지 기본적인 Interface UI는 구현이 된 상태이고, DynamicInventoryUI를 구현해 보도록 하겠습니다.

public class DynamicInventoryUI_New : InventoryUI_New
{
    [SerializeField]
    protected GameObject slotPrefab; // slot에 대한 UI 오브젝트 Prefab
    
    // 격자 형태의 slot 구성.
    [SerializeField]
    protected Vector2 start;
    [SerializeField]
    protected Vector2 size;
    [SerializeField]
    protected Vector2 space;
    
    [Min(1), SerializeField]
    protected int numberOfColumn = 4;
    
    public override void CreateSlotUIs() {
        slotUIs = new Dictionary<GameObject, InventorySlot_New>();
        
        for (int i = 0; i < inventoryObject.Slots.Length; i++) {
            GameObject go = Instantiate(slotPrefab, Vector3.zero, Quaternion.identity, transform);
            go.GetComponent<RectTransform>().anchoredPosition = CalculatePosition(i);
            
            // 마우스 이벤트 트리거들 추가.
            AddEvent(go, EventTriggerType.PointerEnter, delegate { OnEnterSlot(go); });
            AddEvent(go, EventTriggerType.PointerExit, delegate { OnExitSlot(go); });
            AddEvent(go, EventTriggerType.BeginDrag, delegate { OnStartDrag(go); });
            AddEvent(go, EventTriggerType.EndDrag, delegate { OnEndDrag(go); });
            AddEvent(go, EventTriggerType.Drag, delegate { OnDrag(go); });
            
            inventoryObject.Slots[i].slotUI = go;
            slotUIs.Add(go, inventoryObject.Slots[i]);
            
            go.name += ": " + i;
        }
    }
    
    public void CalculatePosition(int i) {
        float x = start.x + ((space.x + size.x) * (i % numberOfColumn));
        float y = start.y + (-(start.y) + size.y) * (i / numberOfColumn));
        return new Vector3(x, y, 0f);
    }
}


CreateSlotUIs()가 가장 중요한 함수라고 볼 수 있겠습니다.

이와 관련하여 SlotPrefab의 구성은 다음과 같아야 합니다.





Slot의 배경 이미지와 Icon이 들어갈 위치, 그리고 하단에 Text가 배치되어 있어야 합니다. 왜냐면 소스 코드 구성시 Slot의 구성이 Icon과 Text가 무조건 존재해야만 구동이 되도록 프로그래밍되어 있기 때문입니다.


그리고 위에 구성한 DynamicInventoryUI_New 스크립트를 Unity UI 상에서 설정해줍니다.



DynamicInventory에다가 DynamicInventoryUI_New 스크립트를 추가하여주고,
+ Inventory Object = "PlayerInventory"
+ Slot Prefab = "SlotPrefab"
+ Start = -70, 120
+ Size = 50, 50
+ Space = 1, 1
+ Number Of Column = 4
으로 설정하여 줍니다.




플레이를 해보면 위와 같이 인벤토리 구역 내에 Slot 24개가 잘 배치되어 나타나는 것을 확인할 수 있습니다.

이것이 잘 동작하는지를 확인하는 TestItems 스크립트를 작성하면 쉽게 동작여부를 확인하기 수월합니다.




AddNewItem() 함수는 databaseObject에서 랜덤하게 아이템을 하나 얻어와서 AddItem()으로 추가해주고,
CreateInventory() 함수는 등록된 inventoryObject를 모두 제거해주는 함수입니다.




플레이를 진행해 봅니다.
Add Item을 클릭하면 랜덤하게 하나씩 추가되고, Slot간 아이템 이동과, 아이템 교체, 인벤토리가 아닌 영역에 드래그하여 아이템 버리기, Cleate Items를 클릭하여 모두 비우기 등의 모든 테스트를 해볼 수 있습니다.




물약과 음식과 같은 겹쳐질 수 있는 아이템 테스트입니다.
그림과 같이 동일한 아이템이 추가되는 경우 Stackable[v]이 켜져 있는 아이템의 경우에는 비어 있는 Slot에 또 추가되는 것이 아니라 기존에 있던 아이템에 동일하게 추가를 하고 Text로 개수를 표시하는 방식으로 구현되었습니다.



이번에는 장비 창 인벤토리를 구현해 보도록 하겠습니다.


public class StaticInventoryUI_New : MonoBehaviour
{
    public GameObject[] staticSlots = null; // 고정된 Slot.
    
    public override void CrateSlotUIs() {
        slotUIs = new Dictionary<GameObject, FastCampus.InventorySystem.Inventory.InventorySlot_New>();
        for (int i = 0; i < inventoryObject.Slots.Length; i++) {
            GameObject go = staticSlots[i]; // Scene에 등록된 고정된 UI를 사용함이 다른점.
            go.GetComponent<RectTransform>().anchoredPosition = CalculatePosition(i);
            
            // 마우스 이벤트 트리거들 추가.
            AddEvent(go, EventTriggerType.PointerEnter, delegate { OnEnterSlot(go); });
            AddEvent(go, EventTriggerType.PointerExit, delegate { OnExitSlot(go); });
            AddEvent(go, EventTriggerType.BeginDrag, delegate { OnStartDrag(go); });
            AddEvent(go, EventTriggerType.EndDrag, delegate { OnEndDrag(go); });
            AddEvent(go, EventTriggerType.Drag, delegate { OnDrag(go); });
            
            inventoryObject.Slots[i].slotUI = go;
            slotUIs.Add(go, inventoryObject.Slots[i]);
            
            go.name += ": " + i;
        }
    }
}





여기까지 작업하고 플레이를 해봅니다.
보시는 것과 같이 장비 착용 인벤토리에 아이템을 드래그하여 설정이 가능하고, 또한 동일한 타입이 아닌 아이템은 착용이 되지 않는 것도 확인할 수 있습니다.





이제는 캐릭터 장비 교체를 구현해보는 시간입니다 ^^~
캐릭터는 보통 다수의 Skinned mesh와 Static mesh로 구현되어 있습니다.

Skinned mesh는 그림의 사람처럼 Bone이라는 뼈대로 구성된 것 위에 영향을 받는 Vertex들이 구성되어 있는 mesh 구조입니다. 투구나 갑옷 등 몸에 착용되는 아이템은 1개의 뼈대를 가지는 아이템으로 구성되는 경우가 많습니다.
Static mesh는 우측 하단 그림의 칼처럼 Bone이 없이 Vertex들로만 구성된 Mesh Object 구조입니다.

Skinned mesh, 즉 사람 GameObject에 아이템을 입히는 효과를 처리하려면, Human Bone 구조를 가지는 것 하나에 아이템들을 얻혀서 하나처럼 조합시키는 작업을 해주어야 합니다.




EquipmentCombiner가 뼈대 정보를 가지고 있다고 보면 됩니다.


public class EquipmentCombiner_New
{
    private readonly Dictionary<int, Transform> rootBoneDictionary = new Dictionary<int, Transform>(); // 모든 뼈대 정보
    private readonly Transform transform;
    
    public EquipmentCombiner_New(GameObject rootGO) {
        transform = rootGO.transform;
        TraverseHierachy(transform);
    }
    
    public Transform AddLimb(GameObject itemGO, List<string> boneNames) {
        Transform limb = ProcessBoneObject(itemGO.GetComponentInChildren<SkinnedMeshRenderer>(), boneNames);
        limb.SetParent(transform);
        return limb;
    }
    
    // 새로운 게임 오브젝트에 기존의 Skinned Mesh를 복사.
    private Transform ProcessBoneObject(SkinnedMeshRenderer renderer, List<string> boneNames) {
        Transform itemTransform = new GameObject().transform;
        SkinnedMeshRenderer meshRenderer = itemTransform.gameObject.AddComponent<SkinnedMeshRenderer>();
        Transform[] boneTransforms = new Transform[boneNames.Count];
        for (int i = 0; i < boneNames.Count; i++) {
            boneTransforms[i] = rootBoneDictionary[boneNames[i].GetHashCode()];
        }
        
        meshRenderer.bones = boneTransforms;
        meshRenderer.sharedMesh = renderer.sharedMesh;
        meshRenderer.materials = renderer.sharedMaterials;
        
        return itemTransform;
    }
   
    // Bone을 포함하지 않는 Static Mesh 처리
    public Transform[] AddMesh(GameObject itemGO) {
        Transform[] itemTransforms = ProcessMeshObject(itemGO.GetComponentsInChildren<MeshRenderer>());
        return itemTransforms;
    }
    
    public Transform[] ProcessMeshObject(MeshRenderer[] meshRenderers) {
        List<Transform> itemTransforms = new List<Transform>();
        foreach (MeshRenderer renderer in meshRenderers) {
            if (renderer.transform.parent != null) {
                Transform parent = rootBoneDictionary[renderer.transform.parent.name.GetHashCode()];
                GameObject itemGO = GameObject.Instantiate(renderer.gameObject, parent);
                itemTransforms.Add(itemGO.transform);
            }
        }
        return itemTransforms.ToArray();
    }
    
    private void TraverseHierachy(Transform root) {
        foreach (Transform child in root) {
            rootBoneDictionary.Add(child.name.GetHashCode(), child);
            TraverseHierachy(child);
        }
    }
}




public class PlayerEquipment_New : MonoBehaviour
{
    public InventoryObject equipment;
    private EquipmentCombiner_New combiner;
    private ItemInstances_New[] itemInstances = new ItemInstances_New[8];
    public ItemObject[] defaultItemObjects = new ItemObject[8];
}

여기까지가 구현의 일부입니다. 다음 시간에 이어서 구현을 합니다.



오늘은 굉장히 중요한 내용들이 많았습니다. 작성하기도 힘들었네요 헉헉..
인벤토리 구성을 위한 스크립트와 Unity 설정까지 모두 알아보았으며,
인벤토리에서 장비장착 인벤토리로 아이템을 설정할 때 실제 캐릭터에 장착되는 구현을 하고 있습니다.
그러다보니 중요한 내용도 많고 코드도 많고, Unity 설정도 엄청 많습니다. ㅎㅎ
실제로 처음부터 백지에서 시작해서 구현하려면 엄청난 시간이 필요할 것 같네요 ㅠ.,ㅜ;





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

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

 

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

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

www.fastcampus.co.kr

 


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


02 디아블로 게임 - 31. 인벤토리와 아이템 구현하기 - 02 인벤토리 시스템 구현하기
02 디아블로 게임 - 32. 인벤토리와 아이템 구현하기 - 03 플레이어와 상점을 위한 인벤토리 UI 구현하기




ItemDatabaseobject는 모든 아이템을 가지는 형태로 고려되어 있기때문에 InventoryObject에 할당될 때 문제가 없지만, 칼/투구/장갑 등 종류별로 ItemDatabaseObject를 구성하고자 하는 경우에는 id의 충돌이 발생하게 됩니다.
이를 간단히 피하기 위해서 baseId를 추가하여 처리하는 방식을 고려할 수 있습니다. 칼은 100번대, 투구는 200번대, 장갑은 300번대 baseId를 고정시켜두고 내부 id가 baseId에서 증가되는 형태로 제작할 수 있는 것입니다.





InventoryObject_New 스크립트를 생성합니다. UI와 관련이 높은 클래스입니다.

public enum InterfaceType_New { // 인벤토리 종류
    Inventory,
    Equipment,
    QuickSlot,
    ChestBox,
}

[CreateAssetMenu(fileName = "New Inventory", menuName = "Inventory System/Inventory_New")]
public class InventoryObject_New : ScriptableObject
{
    public ItemObjectDatabase_New database;
    public InterfaceType_New type;
    
    [SerializeField]
    private Inventory_New container = new Inventory_New();
    
    public InventorySlot[] Slots => container.slots;
    
    public int EmptySlotCount {
        get {
            int counter = 0;
            foreach (InventorySlot_New slot in Slots)
                if (slot.item.id < 0)
                    counter++;
            return counter;
        }
    }
    
    public bool AddItem(Item_New item, int amount) {
        if (EmptySlotCount <= 0) return false;
        InventorySlot_New slot = FindItemInInventory(item);
        if (!database.itemObjects[item.id].stackable || slot)
            GetEmptySlot().AddItem(item, amount);
        else
            slot.AddAmount(amount);
        return true;
    }
    
    public InventorySlot_New FindItemInInventory(Item_New item) {
        return Slots.FindOrDefault(i => i.item.id == item.id);
    }
    
    public InventorySlot_New GetEmptySlot() {
        return Slots.FirstOrDefault(i => i.item.id < 0);
    }
    
    public bool IsContainItem(ItemObject_New itemObject) {
        return Slots.FirstOrDefault(i => i.item.id == itemObject.data.id) != null;
    }
    
    public void SwapItems(InventorySlot_New itemSlotA, InventorySlot_New itemSlotB) {
        if (itemSlotA == itemSlotB) return;
        if (itemSlotB.CanPlaceInSlot(itemSlotA.ItemObject) && itemSlotA.CanPlaceInSlot(itemSlotB.ItemObject)) {
            InventorySlot tempSlot = new InventorySlot_New(itemSlotB.item, itemSlotB.amount);
            itemSlotB.UpdateSlot(itemSlotA.item, itemSlotA.amount);
            itemSlotA.UpdateSlot(tempSlot.item, tempSlot.amount);
        }
    }
}




이제 InventoryUI를 구현해볼 차례입니다.



UI 시스템은 다른 입력시스템이나 이벤트 트리거 등과 연관이 많이 되는 부분이라 복잡해지는 경향이 있습니다.

여기에서 InventoryUI는 실제 InventorySlot UI와 EventManager를 중재해주는 컨트롤러의 역할입니다.

즉, InventoryObject라는 Model, InventorySlot & EventManager라는 View, InventoryUI라는 Controller로 구분하여 MVC 모델을 구현한 것입니다.

StaticInventoryUI는 형태가 고정된 인벤토리 UI이며, DynamicInventoryUI는 형태가 고정되지 않은 인벤토리 UI입니다.
StaticInventoryUI는 장비창, DynamicInventoryUI는 인벤토리창으로 구현합니다.


 


public static class MouseData_New {
    public static InventoryUI interfaceMouseIsOver; // 어느 UI에 있는가?
    public static GameObject slotHoveredOver; // 어느 Slot 위에 있는가?
    public static GameObject tempItemBeingDragged;
}

[RequireComponent(typeof(EventTrigger))]
public abstract class InventoryUI_New : MonoBehaviour
{
    public InventoryObject_New inventoryObject;
    private InventoryObject_New previousInventoryObject;
    
    public Dictionary<GameObject, InventorySlot_New> slotUIs = new Dictionary<GameObject, InventoryObject_New>();
    
    private void Awake() {
        CreateSlotUIs();
        for (int i = 0; i < inventoryObject.Slots.Length; i++) {
            inventoryObject.Slots[i].parent = inventoryObject; // 초기화
            inventoryObject.Slots[i].OnPostUpdate += OnPostUpdate;
        }
        
        AddEvent(gameObject, EventTriggerType.PointerEnter, delegate { OnEnterInterface(gameObject); });
        AddEvent(gameObject, EventTriggerType.PointerExit, delegate { OnExitInterface(gameObject); });
    }
   
    protected virtual void Start() {
        for (int i = 0; i < inventoryObject.Slots.Length; i++) {
            inventoryObject.Slots[i].UpdateSlot(inventoryObject.Slots[i].item, inventoryObject.Slots[i].amount);
        }
    }
    
    public abstract CreateSlotUIs();
    
    protected void AddEvent(GameObject go, EventTriggerType type, UnityAction<BaseEventData> action) {
        EventTrigger trigger = go.GetComponent<EventTrigger>();
        if (!trigger) {
            Debug.LogWarning("No EventTrigger component found!");
            return;
        }
        
        EventTrigger.Entry eventTrigger = new EventTrigger.Entry { eventID = type };
        EventTrigger.callback.AddListener(action);
        trigger.triggers.Add(eventTrigger);
    }
    
    public void OnPostUpdate(InventorySlot_New slot) {
        slot.slotUI.transform.GetChild(0).GetComponent<Image>().sprite = slot.item.id < 0 ? null : slot.ItemObject.icon;
        slot.slotUI.transform.GetChild(0).GetComponent<Image>().color = slot.item.id < 0 ? new Color(1, 1, 1, 0) : new Color(1, 1, 1, 1);
        slot.slotUI.GetComponentInChildren<TextMeshProUGUI>().text = slot.item.id < 0 ? string.Empty : (slot.amount == 1);
    }
    
    public void OnEnterInterface(GameObject go) {
        MouseData_New.interfaceMouseIsOver = go.GetComponent<InventoryUI>();
    }
    
    public void OnExitInterface(GameObject go) {
        MouseData_New.interfaceMouseIsOver = null;
    }
    
    public void OnEnterSlot(GameObject go) {
        MouseData_New.slotHoveredOver = go;
    }
    
    public void OnExitSlot(GameObject go) {
        MouseData_New.slotHoveredOver = null;
    }
    
    public void OnStartDrag(GameObject go) {
        MouseData_New.tempItemBeingDragged = CreateDragImage(go);
    }
   
    public void CreateDragImage(GameObject go) {
        if (slotUIs[go].item.id < 0) return null;
        GameObject dragImageGo = new GameObject();
        RectTransform rectTransform = dragImageGo.AddComponent<RectTransform>();
        rectTransform.sizeDelta = new Vector2(50, 50);
        dragImageGo.transform.SetParent(transform.parent);
        Image image = dragImageGo.AddComponent<Image>();
        image.sprite = slotUIs[go].ItemObject.icon;
        image.raycastTarget = false;
        dragImageGo.name = "Drag Image";
        return dragImageGo;
    }
   
    public void OnDrag(GameObject go) {
        if (MouseData_New.tempItemBeingDragged == null) return;
        MouseData_New.tempItemBeingDragged.GetComponent<RectTransform>().position = Input.mousePosition;
    }
    
    public void OnEndDrag(GameObject go) {
        Destroy(MouseData_New.tempItemBeingDragged);
        if (MouseData_New.interfaceMouseIsOver == null)
            slotUIs[go].RemoveItem();
        else if (MouseData_New.slotHoveredOver) {
            InventorySlot_New mouseHoverSlotData = MouseData_New.interfaceMouseIsOver.slotUIs[MouseData_New.slotHoveredOver];
            inventoryObject.SwapItems(slotUIs[go], mouseHoverSlotData);
        }
    }
}


마우스가 아이콘을 클릭하여 드래그를 시작하면 임시 이미지를 만들어서 드래그중에는 마우스포인터 위치에 임시 이미지가 따라다니도록 하는 루틴입니다.
마우스를 특정 위치에서 놓았을 때에는 특정 UI에 있지 않은 경우라면 아이템을 버리는 것으로 보고 삭제합니다.
인터페이스위 슬롯 위에 있다면 아이템을 서로 교체하는 로직이 구동되게 됩니다.

 

 

오늘도 역시 눈은 즐겁지만 머리는 뱅글뱅글 도는 시간이 되었네요..



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

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

 

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

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

www.fastcampus.co.kr

 


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




이제부터는 게임내에 인벤토리 시스템을 구현하고, 장비 시스템과 아이템 시스템을 구현합니다.
오늘은 아이템 시스템 구현하기와 플레이어와 상점을 위한 인벤토리 시스템 구현하기의 시작입니다.

인벤토리 시스템은 Scriptable Object를 사용합니다. 이를 사용하면 Game내 데이터 관리와 전역적 접근을 쉽게 해줍니다.




+ 아이템 시스템 구현하기
+ 인벤토리 시스템 구현하기 - 구현된 아이템 시스템 이용.
+ 인벤토리 UI 구현하기 - 인벤토리 시스템에 UI 구현
+ 캐릭터 장비 교체 구현하기
+ 아이템 획득/사용 구현하기





가장 기본이되는 것은 Item 클래스입니다. 캐릭터의 스텟을 변화시키는 기능을 해주는 것이고, Item의 buffs에 ItemBuff 오브젝트를 생성하여 Attach하는 방식으로 구현할 수 있습니다.
ItemObject는 Item을 가지는 데이터 오브젝트입니다.
Item은 캐릭터에 대한 행동을 명시하고, ItemObject는 캐릭터의 행동과 표현방법, 설명 등을 포함하는 것입니다.
그리고 이를 List화하여 가지고 있는 ItemDatabaseObject를 구현하게 됩니다.

먼저 Item 행동에 해당하는 ItemBuff와 Item, ItemObject 컴포넌트 스크립트를 작성합니다.



ItemBuff Script

public enum CharacterAttribute
{
    Agility,
    Intellect,
    Stamina,
    Strength
}

[Serializable]
public class ItemBuff
{
    public CharacterAttribute stat;
    public int value;
    
    [SerializeField]
    private int min;
    [SerializeField]
    private int max;
    
    public int Min => min;
    public int Max => max;
    
    public ItemBuff(int min, int max) {
        this.min = min;
        this.max = max;
        
        GenerateValue();
    }
    
    public void GenerateValue() {
        value = UnityEngine.Random.Range(min, max); // 획득 아이템 랜덤 값
    }
    
    public void AddValue(ref int v) {
        v += value;
    }
}



Item Script

[Serializable]
public class Item
{
    public int id = -1;
    public string name;
    public ItemBuff[] buffs;
    
    public Item() {
        id = -1; // 비어있는 아이템
        name = "";
    }
    
    public Item(ItemObject itemobject) {
        name = itemObject.name;
        id = itemobject.data.id;
        buffs = new ItemBuff[itemObject.data.buffs.Length];
        for (int i = 0; i < buffs.Length; i++) {
            buffs[i] = new ItemBuff(itemobject.data.buffs[i].Min, itemObject.data.buffs[i].Max) {
                stat = itemObject.data.buffs[i].stat;
            };
        }
    }
}




ItemObject Script

public enum ItemType: int
{
    Helmet = 0,
    Chest = 1,
    Pants = 2,
    Boots = 3,
    Pauldrons = 4,
    Gloves = 5,
    LeftWeapon = 6,
    RightWeapon = 7,
    Food = 8,
    Default,
}

[CreateAssetMenu(fileName = "New Item", menuName = "Inventory System/Items/New Item")]
public class ItemObject : ScriptableObject
{
    public ItemType type;
    public bool stackable;
    public Sprite icon;
    public GameObject modelPrefab;
    public Item data = new Item();
    public List<string> boneNames = new List<string>();
    
    [TextArea(15, 20)]
    public string description;
   
    private void OnValidate() {
        boneNames.Clear();
        if (modelPrefab == null || modelPrefab.GetComponentInChildren<SkinnedMeshRenderer>() == null) {
            return;
        }
        
        SkinnedMeshRenderer renderer = modelPrefab.GetComponentInChildren<SkinnedMeshRenderer>();
        Transform[] bons = renderer.bones;
        
        for (Transform t in bones) {
            boneNames.Add(t.name);
        }
    }
    
    public Item CrateItem() {
        Item newItem = new Item(this);
        return newItem;
    }
}



여기까지하여 컴파일이 정상적으로 되면 아래와 같이 Create - Inventory System - Items - New Item 과 같이 추가된 것을 확인할 수 있습니다. 허거거걱... 머리가 핑핑... 눈도 핑핑 @v@~



 

 




이렇게 추가한 아이템들을 관리하는 ItemObjectDatabase 스크립트를 작성합니다. ㅠ


[CreateAssetMenu(fileName = "New Item Database", menuName = "Inventory System/Items/Database_New")]
public class ItemObjectDatabase : ScriptableObject
{
    public ItemObject[] itemObjects;
    
    public void OnValidate() {
        for (int i = 0; i < itemObjects.Length; i++) {
            itemObjects[i].data.id = i;
        }
    }
}


어마어마하네요.. CreateAssetMenu() 함수를 통해서 Unity에 원하는 형태로 Menu를 등록하고, Item 등의 컴포넌트도 생성할 수 있도록 구성한데다가, ItemObjectData를 구현해서 Unity UI 상에서 그냥 드래그해서 자동 추가가 되어버리다니... 헐...
무언가 할당하는 개념이 없고 UI에서 생성, 할당하는 부분들이 많아서 적응은 잘 되지 않고, 언제 왜 써야하는지 애매하긴 하지만 일단 어마한 기능이라고 밖에 말할 수 없겠네요.






이제 Inventory 시스템 구현입니다.
여기서의 메인은 InventorySlot 입니다. 이것은 아이템이 놓이는 공간이지요. 여기서 아이템이 추가, 삭제될 때마다 갱신 메시지를 발생시켜서 Action으로 관리하고, type 지정으로 해당하는 위치에만 놓이도록 하게 만들 예정입니다.

Inventory 부분은 Inventory UI와 연결되는 부분입니다

 



Item을 ItemObject에서 분리하고, Inventory를 InventoryObject와 구분하여 만든 이유는,
Item과 ItemBuff 내용을 그대로 Json으로 저장하기 편하며, Inventory도 클래스 그대로 Json으로 저장하기 편하기 때문입니다.
ItemObject나 InventoryObject는 데이터를 뽑아내는 과정을 거쳐야 처리할 수 있는데 말이지요.
Json으로 저장하지 않는다면 한 클래스로 구현하는 것이 관리상의 편의점은 있겠습니다.


[Serializable]
public class InventorySlot
{
    public ItemType[] allowedItems = new ItemType[0];
    
    [NonSerialized] // Json 저장시 제외
    public InventoryObject parent;
    [NonSerialized]
    public GameObject slotUI;
    [NonSerialized]
    public Action<InventorySlot> OnPreUpdate;
    [NonSerialized]
    public Action<InventorySlot> OnPostUpdate;
    
    public Item item;
    public int amount;
    public ItemObject ItemObject {
        get {
            return item.id >= 0 ? parent.database.itemObjects[item.id] : null;
        }
    }
    
    public InventorySlot() => UpdateSlot(new Item(), 0);
    public InventorySlot(Item item, int amount) => UpdateSlot(item, amount);
   
    public void RemoveItem() => UpdateSlot(new Item(), 0);
    public void AddAmount(int value) => UpdateSlot(item, amount += value);
    
    public void UpdateSlot(Item item, int amount) {
        OnPreUpdate?.Invoke(this);
        this.item = item;
        this.amount = amount;
        OnPostUpdate?.Invoke(this);
    }
    
    public bool CanPlaceInSlot(ItemObject itemObject) {
        if (allowedItems.Length <= 0 || itemObject == null || itemObject.data.id < 0) {
            return true;
        }
        
        foreach (ItemType type in allowedItems) {
            if (itemObject.type == type) {
                return true;
            }
        }
        
        return false;
    }
}



[Serializable]
public class Inventory
{
    public InventorySlot[] slots = new InventorySlot[24];
    
    public void Clear() {
        foreach (InventorySlot slot in slots) {
            slot.RemoveItem(); // or slot.UpdateSlot(new Item(), 0);
        }
    }
   
    public bool IsContain(ItemObject itemObject) {
        return IsContain(itemObject.data.id);
    }
    
    public bool IsContain(int id) {
        return slots.FirstOrDefault(i => i.item.id == id) != null;
    }
}





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

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


 

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

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

www.fastcampus.co.kr

 

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


전투 시스템의 NPC 전투 UI 구현하기 입니다.




항상 카메라를 바라보는 Billboard 기법라는 것을 사용하여 만들어지게 됩니다.
캐릭터와 UI가 회전을 하더라도 항상 카메라를 바라보게 해서 유저가 항상 동일한 형태의 UI를 볼 수 있도록 하는 기법입니다.

이러한 Billboard를 구현하기 위해 Camera Facing이라는 컴포넌트 스크립트를 구현하도록 합니다.


public class CameraFacing_New : MonoBehaviour
{
    Camera referenceCamera;
    public bool reverseFace = false; // 고정이되는 축과 향하는 방향이 반대로 보여주는 flag
    
    public enum Axis // 특정 축을 기준으로 회전.
    {
        up, down, left, right, forward, back
    }
    public Axis axis = Axis.up;
    
    public Vector3 GetAxis(Axis refAxis)
    {
        switch (refAxis) {
            case Axis.down: return Vector3.down;
            case Axis.forward: return Vector3.forward;
            case Axis.back: return Vector3.back;
            case Axis.left: return Vector3.left;
            case Axis.right: return Vector3.right;
        }
        return Vector3.up;
    }
    
    private void Awake()
    {
        if (!referenceCamera) {
            referenceCamera = Camera.main;
        }
    }
    
    private void LateUpdate()
    {
        Vector3 targetPos = transform.position + referenceCamera.transform.rotation * (reverseFace ? Vector3.forward : Vector3.back);
        Vector3 targetOrientation = referenceCamera.rotation * GetAxis(axis);
        
        transform.LookAt(targetPos, targetOrientation);
    }
}





UI에서 "Slider"를 추가하면 "Canvas"의 자식으로 "Slider"가 추가됩니다.
+ Render Mode를 "Screen Space - Overlay"에서 "World Space"로 변경합니다.
+ Pos X, Y, Z도 모두 "0"으로 지정합니다.
+ Scale X, Y, Z를 모두 "0.05"로 설정합니다.
+ Width = 100, Height = 20




일단 실행해 보면 캐릭터이 회전에 따라 체력바가 따라서 회전하는 것을 볼 수 있는데, 어색하지요? 아직 CameraFacing을 연동하지 않았기 때문입니다.

Canvas에 Camera Facing 컴포넌트를 추가하고 실행하면 아래와 같이 캐릭터가 회전하더라도 Y축으로 고정된 체력바가 고정되어 표시되는 것을 확인할 수 있습니다.





이제 여기 NPC에 HP를 변경하는 스크립트를 추가하도록 하겠습니다.


public class NPCBattleUI_New : MonoBehaviour
{
    private Slider hpSlider;
    
    public float MaximunValue
    {
        get => hpSlider.maxValue;
        set {
            hpSlider.maxValue = value;
        }
    }
    
    public float MinimunValue
    {
        get => hpSlider.minValue;
        set {
            hpSlider.minValue = value;
        }
    }
    
    public float Value
    {
        get => hpSlider.value;
        set {
            hpSlider.value = value;
        }
    }
    
    public void Awake() {
    {
        hpSlider = gameObject.GetComponentInChildren<Slider>();
    }
    
    private void OnEnable()
    {
        GetComponent<Canvas>().enabled = true;
    }
    
    private void OnDisble()
    {
        GetComponent<Canvas>().enabled = false;
    }
}


위와 같이 작업하고 EnemyController_Range_Original.cs에서 battleUI 인스턴스를 사용하여 최대, 최소, 현재 체력값을 설정하도록 수정해 줍니다. 그리고 데미지가 입었을 때 HP가 변화하는 것을 처리하기 위해 아래와 같이 추가해줍니다.

public void TakeDamage(int damage, GameObject hitEffectPrefab)
{
    // 기존 코드 유지
    
    health -= damage;
    
    if (battleUI) {
        battleUI.Value = health;
    }
    
    // 기존 코드 유지
}





현재까지 상태로 실행을 해보면 적캐릭터가 데미지를 입을 때마다 HP가 주는 것을 확인할 수 있습니다. 단지 HP나 숫자가 반대로 표시되는 것을 볼 수 있습니다.

카메라를 UI가 바라보는 상황이 되어 유저가 바라보는 방향이 Flip 상태로 되기 때문에 "Reverse Face [v]"를 체크하여 유저가 바라보는 방향으로 맞추어진 UI로 만들어줍니다.




그 상태로 플레이를 진행하면 정상적으로 표시되는 것을 확인할 수 있습니다.



데미지 관련하여서도.. (캐릭터 위에 표시되는 숫자값) 동일하게 NPC UI를 활용하여 구현하면 됩니다.



TextMeshPro를 사용하여 보도록 하겠습니다. Damage값을 애니메이션으로하여 위로 튀는 것처럼 보여주면 좀더 재미있는 효과가 되므로 해당 애니메이션을 추가하도록 합니다.

 

 



Text 애니메이션 작업은 시간이 걸리는 관계로 미리 제작된 내용으로 진행합니다.
여튼 동작은 그렇습니다. Text가 위로 통통 튀는 애니메이션이고, 위로 올라갈때 Text Alpha값이나, Scale값등이 적용된 애니메이션이라고 보면 됩니다.

동일하게 NPCBattleUI 로 구현한 것이므로 해당 클래스에서 처리하면 됩니다. 추가된 코드만 작성합니다.


public class NPCBattleUI_New : MonoBehaviour
{
    [SerializeField]
    private GameObject damageTextPrefab;
    
    public void CrateDamageText(int damage)
    {
        if (damageTextPrefab != null) {
            GameObject damageTextGO = Instantiate(damageTextPrefab, transform);
            DamageText damageText = damageTextGO.GetComponent<DamageText>();
            if (damageText == null) {
                Destroy(damageTextGO);
            }
            
            damageText.Damage = damage;
        }
    }
}

// 특정 시간후에 DamageText가 사라지도록 구현
public class DamageText_New : MonoBehaviour
{
    public float delayTimeToDestroy = 1.0f;
    
    void Start()
    {
        Destroy(gameObject, delayTimeToDestroy);
    }
}




실행을 해보면 TakeDamage()가 발생할 때마다 DamageText가 애니메이션으로 잘 동작하는 것을 확인할 수 있습니다.
여기까지가 NPC UI를 사용하여 적의 HP바와 DamageText를 표시하도록 구현이 완료되었습니다.

 




이제 캐릭터의 이동지점을 클릭하였을 때 클릭 지점을 표시하는 기능을 만들어 보겠습니다.
Pointer를 하나 추가하고 Position Y 값을 -100으로 설정합니다. Pointer의 Enable/Disable을 사용하여 보였다 안 보였다 하는 형태를 취할 수도 있고, 여기에서와 같이 -100 위치에 있다가 클릭 위치로 순간 이동시켜서 표시하는 방법도 있습니다.

해당 Pointer에 Image를 추가하여 아래와 같이 설정한 상태로 진행합니다.




이제 클릭 지점에 해당 Pointer Image가 표시되도록 하는 컴포넌트 스크립트를 작성하면 되겠지요~


public class PlaceTargetWithMouse : MonoBehaviour
{
    public float surfaceOffset = 1.5f; // gameObject의 위치에서 조금 높여주기 위해.. 지면과 겹치는 문제 해결위해
    public Transform target = null;
    
    private void Update()
    {
        if (target) {
            transform.position = target.position + Vector3.up * surfaceOffset;
        }
    }
    
    // 마우스 클릭 hit 정보
    public void SetPosition(Raycast hit)
    {
        target = null;
        transform.position = hit.point + hit.normal * surfaceOffset;
    }
}


그리고 PlayerCharactor에서 마우스 클릭을 할 때 적이 아니라면,

if (picker) picker.SetPosition(hit);

코드를 추가해 주면 되고,

만약 적이라면,

if (picker) picker.target = hit.collider.transform;

코드를 추가해 주면 됩니다.




실행해보면 잘 동작하는 것을 확인할 수 있는데요..
여기서의 문제점은 굴곡이 있는 지형이나 벽에서 클릭을 할 경우 파묻히는 현상이 나타날 수 있습니다.

파묻힘을 해결하기 위해서는 데칼 기법이나 프로젝터 기법을 사용하면 됩니다.
이런 구현은 유니티 기본 샘플에 포함되어 있으니 필요한 경우 가져다 사용하면 됩니다.



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

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


 

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

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

www.fastcampus.co.kr

 

+ Recent posts