본문 바로가기
Unity/스크립터블오브젝트

Scriptable Object - 응용: Dual Serialization, 세이브 & 로드 (8)

by PlaneK 2020. 6. 3.

Dual Serialization

JSON Utility를 통해 Scriptable Object의 인스턴스를 .asset 뿐만 아니라 JSON 파일의 문자열(string)로 직렬화 할 수 있다.

Desiralization 방법

1
2
// Load built-in level from an AssetBundle
level = lvlsBundle.LoadAsset<LevelLayout>("lvl1.asset");

첫 번째 방법은 .asset을 역직렬화하는 것이다. 역직렬화된 .asset파일의 인스턴스를 그대로 사용한다.

1
2
3
4
// Load level from JSON
level = CreateInstance<LevelLayout>();
var json = File.ReadAllText("customlevel.json");
JsonUtility.FromJsonOverwrite(json, level);

두 번째 방법은 새로 생성한 SO 인스턴스에 JSON 데이터를 Overwrite하는 것이다. 

JSON을 사용하면 .asset파일의 미리 정의된 값을 사용하지 않아도 된다.

 

세이브 & 로드

메인메뉴 UI가 출력하는 세팅 값들은 JSON 파일로부터 초기화된다.

Play버튼을 누르면 메인메뉴 UI는 사용자가 입력한 세팅 값을 JSON 파일로 저장한다. 

저장된 JSON 파일이 없으면 'Default Game Setting'이라는 .asset파일로부터 초기화된다. 

JSON 파일로 저장하고 로드하는 플로우를 정리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
using System;
using UnityEngine;
using System.Collections.Generic;
using System.Linq;
 
/* ScriptableObject와 MonoBehaviour의 파생 클래스는 [Serializable]이 디폴트다. */
// 직렬화 대상 필드 : "{Players[{Name, Color, BrainName}], NumberOfRounds}"
[CreateAssetMenu]
public class GameSettings : ScriptableObject
{
    
    [Serializable]
    public class PlayerInfo
    {
        /* [Serializable] class의 public 필드는 직렬화 대상이다. (프로퍼티 제외) */
        // 3가지 필드(Name, Color, BrainName)가 직렬화된다.
        public string Name;
        public Color Color;
 
        private TankBrain _cachedBrain;
        // 필드 Brain은 프로퍼티이므로 직렬화 대상에서 제외된다.
        public TankBrain Brain
        {
            get
            {
                if (!_cachedBrain && !String.IsNullOrEmpty(BrainName))
                {
                    TankBrain[] availableBrains;
 
                    #if UNITY_EDITOR
                    // 에디터에서 플레이 시에는 메모리에서 찾을 수 없으므로 AssetDatabase를 사용한다.
                    availableBrains = UnityEditor.AssetDatabase.FindAssets("t:TankBrain")
                                    .Select(guid => UnityEditor.AssetDatabase.GUIDToAssetPath(guid))
                                    .Select(path => UnityEditor.AssetDatabase.LoadAssetAtPath<TankBrain>(path))
                                    .Where(b => b).ToArray();
                    #else
                    // 빌드 런타임 시에는 에셋들이 메모리에 로드됐으므로 Find로 찾을 수 있다.
                    availableBrains = Resources.FindObjectsOfTypeAll<TankBrain>();
                    #endif
 
                    _cachedBrain = availableBrains.FirstOrDefault(b => b.name == BrainName);
                }
                return _cachedBrain;
            }
            set
            {
                _cachedBrain = value;
                BrainName = value ? value.name : String.Empty;
            }
        }
 
        /* 에셋을 참조하는 것을 직접 JSON으로 직렬화하면 Instance ID가 저장되는데,
         Instance ID는 모든 Session(프로젝트가 위치할 수 있는 경로)에서 고정된 값이 아니다.
         Instance ID는 Session(프로젝트가 위치할 수 있는 경로)에 따라 가변하는 값이므로
        고정값인 string을 저장하는 것이 안정적이다. ('파일명'도 경로에 포함된다)*/
 
        // priavate이지만 [SerializeField]의 필드이므로 직렬화 대상에 포함된다.
        [SerializeField] private string BrainName;
 
        public string GetColoredName()
        {
            return "<color=#" + ColorUtility.ToHtmlStringRGBA(Color) + ">" + Name + "</color>";
        }
    }
 
    // 직렬화 대상
    public List<PlayerInfo> players;
 
    private static GameSettings _instance;
    public static GameSettings Instance
    {
        // GameSettings는 싱글톤이다.
        get
        {
            // 런타임 상에서는 에셋이 메모리에 로드됐으므로 가져올 수 있다.
            if (!_instance)
                // 싱글톤이므로 에셋들 중 하나만 가지고 초기화한다.
                _instance = Resources.FindObjectsOfTypeAll<GameSettings>().FirstOrDefault();
            // 에디터 상에서는 AssetDatabase를 사용한다
#if UNITY_EDITOR
            if (!_instance)
                // Test game settings.asset을 사용한다.
                InitializeFromDefault(UnityEditor.AssetDatabase.LoadAssetAtPath<GameSettings>("Assets/Test game settings.asset"));
#endif
            return _instance;
        }
    }
 
    public int NumberOfRounds;
 
    public static void LoadFromJSON(string path)
    {
        // 기존 인스턴스는 제거 "cf. Mono에서는 DestroyImmediate보다 Destroy 함수가 더 안전하다"
        if (_instance) DestroyImmediate(_instance);
        // 인스턴스 생성
        _instance = ScriptableObject.CreateInstance<GameSettings>();
        // 인스턴스의 직렬화 필드에 JSON 문자열 덮어쓰기
        JsonUtility.FromJsonOverwrite(System.IO.File.ReadAllText(path), _instance);
        _instance.hideFlags = HideFlags.HideAndDontSave;
    }
 
    public void SaveToJSON(string path)
    {
        Debug.LogFormat("Saving game settings to {0}", path);
        // 이 인스턴스를 JSON 파일로 변환 후 path에 저장
        System.IO.File.WriteAllText(path, JsonUtility.ToJson(thistrue));
    }
 
    public static void InitializeFromDefault(GameSettings settings)
    {
        // 기존 인스턴스 제거
        if (_instance) DestroyImmediate(_instance);
        // 에셋의 인스턴스 할당. 
        // 인스턴스를 그대로 안쓰고 복사본을 생성하는 이유는 HideFlag 설정 때문이다.
        _instance = Instantiate(settings);
        _instance.hideFlags = HideFlags.HideAndDontSave;
    }
}
 

GameSetting은 싱글톤으로 디자인됐다. JSON 또는 에셋을 통해 자신의 필드를 초기화한다.

.meta 파일에는 guid가 발급된다. 외부에서 이동된 메타파일의 guid가 동일하면 해당 .prefab파일명을 변경하고 .meta파일의 guid도 새로 발급하고 reimport한다. (.prefab은 파일명 외 중복이 허용된다)

(.meta파일의 변경이 감지되거나 동일 guid가 둘 이상 발견되면 Reimport된다.)

Prefab이 씬에 배치하면 씬 파일에는 PrefabInstance항목에 target guid에 해당 프리팹의 guid가 직렬화된다.

Reimport에 의해 guid가 바뀌거나 잃어버려서 씬의 참조 링크가 끊어져버릴 수 있다.

따라서 참조값을 저장하는 것(target - fileID:{guid:239489fds32f3}) 보다 string을 저장하는 것이 안전하다.

해당 경로, Asset의 객체를 얻는 API

(Editor 전용)
AssetDatabase.LoadAssetAtPath<T>(경로)
AssetDatabase.LoadAssetAtPath(경로, typeof(T))
Resources.LoadAssetAtPath(경로) : "deprecated"

(Runtime 전용)
Resources.FindObjectsOfTypeAll<T>() : "Resources 폴더 유무에 상관없이 사용 가능"
Resources.Load<T>(경로) : "Resources 폴더 운용 시 사용"
https://docs.unity3d.com/ScriptReference/Resources.Load.html

AssetBundle.LoadFromFile<T>(경로 + "/" + bundleName) : "에셋번들 운용 시 사용"
https://docs.unity3d.com/ScriptReference/AssetBundle.LoadFromFile.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public string SavedSettingsPath {
    get {
        // 다음 문자열을 반환한다.
        // "C:/Users/user/AppData/LocalLow/DefaultCompany/UniteBoston2015TrainingDay\tanks-settings.json"
        return System.IO.Path.Combine(Application.persistentDataPath, "tanks-settings.json");
    }
}
 
void Start () {
    // JSON 파일 유무를 체크
    if (System.IO.File.Exists(SavedSettingsPath))
        // JSON 파일 로드 및 초기화
        GameSettings.LoadFromJSON(SavedSettingsPath);
    else
        // .asset 파일의 인스턴스로 초기화
        GameSettings.InitializeFromDefault(GameSettingsTemplate);
 
    // 'PlayerPanel'의 각 행의 값을 초기화한다.
    foreach(var info in GetComponentsInChildren<PlayerInfoController>())
        info.Refresh();
 
    NumberOfRoundsSlider.value = GameSettings.Instance.NumberOfRounds;
}
 
// 'Play' 버튼을 누르면
public void Play()
{
    // 해당 경로에 JSON으로 저장한다.
    GameSettings.Instance.SaveToJSON(SavedSettingsPath);
    GameState.CreateFromSettings(GameSettings.Instance);
    SceneManager.LoadScene(1, LoadSceneMode.Single);
}
 

Main Menu는 UI에 출력할 값을 얻어내기 위해 GameSetting에게 초기화를 부탁한다.

GameSetting에게 JSON파일이 있는지 체크해서 JSON파일 경로를 넘겨준다.

없으면 자신이 참조하는 GameSetting.asset을 넘겨준다.

저장 경로 API - Getter Only
(모든 경로는 파일 읽기/쓰기 가능)

Application.persistentDataPath = "C:\Users\user\AppData\LocalLow\<패키지명>\<프로젝트명>"
Application.dataPath = "<Project Directory>"
https://docs.unity3d.com/kr/530/ScriptReference/Application-dataPath.html
Application.streamingAssetsPath= "<Project Directory> + /StreamingAssets"
https://docs.unity3d.com/ScriptReference/Application-streamingAssetsPath.html

댓글