using System; using System.IO; using System.Net.Http; using System.Text; using System.Threading.Tasks; using System.Reflection; using System.Collections.Generic; using System.Collections; using UnityEngine; using Newtonsoft.Json.Linq; using System.Linq; using Sirenix.OdinInspector; using UnityEditor; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; public class GoogleSheetManager : Singleton { [BoxGroup("기본 설정")] [Tooltip("true: google sheet, false: local json")] [SerializeField] private bool _isAccessGoogleSheet = true; [BoxGroup("기본 설정")] [Tooltip("구글 시트 -> 확장 프로그램 -> Apps Script -> 새 배포(웹 앱) or 배포 관리 -> 웹 앱 URL(~~~/exec)")] [SerializeField] private string _googleSheetUrl; [BoxGroup("기본 설정")] [Tooltip("적용시킬 시트의 이름을 적고, 여러 개의 시트를 적는 경우 '/'로 구분지어 시트 나열\nex) \"Sheet1/Sheet2\"")] [SerializeField] private string _availSheets = "Sheet1/Sheet2"; [BoxGroup("기본 설정")] [Tooltip("Class, Json, So 생성 위치 \"/GenerateGoogleSheet\"")] [SerializeField] private string _generateFolderPath = "/0.Datas/02.Scripts/GenerateGoogleSheet/AutoCreated"; [Title("버전 복구")] [BoxGroup("버전 복구")] [Tooltip("마지막 업데이트 시간")] [SerializeField, ReadOnly] private string _lastUpdated; #if UNITY_EDITOR [BoxGroup("버전 복구")] [SerializeField, ValueDropdown(nameof(GetVersionOptions))] private int _restoreIndex; #endif [Title("데이터 변경")] [BoxGroup("데이터 변경")] [LabelText("수정자 이름")] [SerializeField, Required("반드시 수정자 이름을 입력해야 합니다\n이력을 남길 때 표시될 사용자 이름입니다.")] private string _editorName; private string JsonPath => $"{Application.dataPath}{_generateFolderPath}/GoogleSheetJson.json"; private const string ChangeLogPath = "Assets/0.Datas/02.Scripts/GenerateGoogleSheet/Logs/GoogleSheetChangeLog.asset"; private string[] _availSheetArray; private string _json; [SerializeField, ReadOnly] private bool _refreshTrigger; private bool _alreadyCreatedSo; public static async Task LoadSo() where T : ScriptableObject { await Addressables.InitializeAsync().Task; string key = typeof(T).Name; var handle = Addressables.LoadAssetAsync(key); await handle.Task; if (handle.Status == AsyncOperationStatus.Succeeded) return handle.Result; Debug.LogError($"[GoogleSheetManager] Addressable 로드 실패: {key}"); return null; } #if UNITY_EDITOR || DEVELOPMENT_BUILD [BoxGroup("데이터 변경")] [Button("데이터 최신화"), EnableIf(nameof(CanFetchData))] private async void FetchGoogleSheet() { _availSheetArray = _availSheets.Split('/'); var prevLog = AssetDatabase.LoadAssetAtPath(ChangeLogPath); string previousJson = prevLog?.Logs.LastOrDefault()?.JsonSnapshot ?? ""; if (_isAccessGoogleSheet) { if (!IsValidGoogleSheetUrl(_googleSheetUrl)) { Debug.LogError("Google Sheet URL이 유효하지 않습니다."); return; } Debug.Log("구글 시트 데이터 읽는 중..."); _json = await LoadDataGoogleSheet(_googleSheetUrl); } else { Debug.Log("Local Json 파일 읽는 중..."); _json = LoadDataLocalJson(); } if (_json == null) { Debug.Log("Json is null. 최신화 실패"); return; } var diffs = GoogleSheetFetchHelper.CompareJsonDiff(previousJson, _json); if (diffs.Count > 0) GoogleSheetDiffViewer.ShowWindow(diffs); bool isJsonSaved = SaveFileOrSkip(JsonPath, _json); GenerateClassFilesPerSheet(_json); _lastUpdated = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); if (isJsonSaved) { _refreshTrigger = true; SaveChangeLog(_json); EditorPrefs.SetBool("GoogleSheetManager_ShouldCreateSO", true); AssetDatabase.Refresh(); } } private bool CanFetchData() { return !string.IsNullOrWhiteSpace(_editorName); } /// /// Json 로그 저장 /// private void SaveChangeLog(string json) { string logsDirectory = Path.GetDirectoryName(ChangeLogPath); if (!Directory.Exists(logsDirectory)) { Directory.CreateDirectory(logsDirectory); AssetDatabase.ImportAsset(logsDirectory); } var log = AssetDatabase.LoadAssetAtPath(ChangeLogPath); if (log == null) { log = ScriptableObject.CreateInstance(); AssetDatabase.CreateAsset(log, ChangeLogPath); } string previousJson = log.Logs.Count > 0 ? log.Logs[^1].JsonSnapshot : null; // 차이 비교 if (!string.IsNullOrEmpty(previousJson)) { string diffResult = GoogleSheetDiffHelper.GenerateDiff(previousJson, json); Debug.Log(diffResult); } string saveTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); log.Logs.Add(new GoogleSheetChangeLog.LogEntry { Editor = _editorName, Timestamp = saveTime, JsonSnapshot = json }); EditorUtility.SetDirty(log); AssetDatabase.SaveAssets(); SaveJsonBackup(json, saveTime); _editorName = null; } /// /// Json 백업 /// private void SaveJsonBackup(string json, string saveTime) { string safeSaveTime = saveTime.Replace(":", "-"); // 윈도우 파일 이름 안전 처리 string folderPath = Path.Combine(Application.dataPath, "0.Datas/02.Scripts/GenerateGoogleSheet/Backups"); if (!Directory.Exists(folderPath)) Directory.CreateDirectory(folderPath); string fileName = $"{safeSaveTime} by {_editorName}.json"; string filePath = Path.Combine(folderPath, fileName); File.WriteAllText(filePath, json); } [BoxGroup("버전 복구")] [Button("선택한 버전과 현재 비교")] private void CompareWithSelectedVersion() { var log = AssetDatabase.LoadAssetAtPath(ChangeLogPath); if (log == null || _restoreIndex < 0 || _restoreIndex >= log.Logs.Count) { Debug.LogWarning("비교할 수 있는 로그가 없습니다."); return; } string restoreJson = log.Logs[_restoreIndex].JsonSnapshot; string currentJson = File.Exists(JsonPath) ? File.ReadAllText(JsonPath) : ""; List diffs = GoogleSheetFetchHelper.CompareJsonDiff(currentJson, restoreJson); if (diffs.Count > 0) { GoogleSheetDiffViewer.ShowWindow(diffs, true); // 현재 → 선택 버전 Debug.Log("[GoogleSheetManager] 선택한 버전과 현재 버전 간의 변경점을 표시합니다."); } else { Debug.Log("[GoogleSheetManager] 변경점 없음."); } } [BoxGroup("버전 복구")] [Button("선택한 버전으로 복구")] private void RestoreSelectedVersion() { var log = AssetDatabase.LoadAssetAtPath(ChangeLogPath); if (log == null || _restoreIndex < 0 || _restoreIndex >= log.Logs.Count) { Debug.LogWarning("복원할 수 있는 로그가 없습니다."); return; } string restoreJson = log.Logs[_restoreIndex].JsonSnapshot; string currentJson = File.Exists(JsonPath) ? File.ReadAllText(JsonPath) : ""; List diffs = GoogleSheetFetchHelper.CompareJsonDiff(currentJson, restoreJson); if (diffs.Count > 0) GoogleSheetDiffViewer.ShowWindow(diffs); // 변경 전 → 변경 후 // 복원 처리 _json = restoreJson; SaveFileOrSkip(JsonPath, _json); CreateGoogleSheetSo(); _lastUpdated = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); Debug.Log($"[{log.Logs[_restoreIndex].Editor}]의 버전으로 복원 완료"); } /// /// 버전 로그 드롭다운 함수 /// private IEnumerable> GetVersionOptions() { var log = AssetDatabase.LoadAssetAtPath(ChangeLogPath); if (log == null) yield break; for (int i = 0; i < log.Logs.Count; i++) { yield return new ValueDropdownItem( $"{i} - {log.Logs[i].Timestamp} by {log.Logs[i].Editor}", i); } } /// /// 구글 시트 데이터 읽어오기 /// private async Task LoadDataGoogleSheet(string url) { using (HttpClient client = new HttpClient()) { try { byte[] dataBytes = await client.GetByteArrayAsync(url); return Encoding.UTF8.GetString(dataBytes); } catch (HttpRequestException e) { Debug.LogError($"Request error: {e.Message}"); return null; } } } /// /// jSON 데이터 파일 읽어오기 /// private string LoadDataLocalJson() { if (File.Exists(JsonPath)) { return File.ReadAllText(JsonPath); } Debug.Log($"Json 파일이 존재하지 않습니다\n{JsonPath}"); return null; } /// /// 파일 생성 및 비교 /// private bool SaveFileOrSkip(string path, string contents) { string directoryPath = Path.GetDirectoryName(path); if (!Directory.Exists(directoryPath)) { Directory.CreateDirectory(directoryPath); } if (File.Exists(path) && File.ReadAllText(path).Equals(contents)) return false; File.WriteAllText(path, contents); return true; } private bool IsExistAvailSheets(string sheetName) { return Array.Exists(_availSheetArray, x => x == sheetName); } /// /// 유효한 구글 웹 앱 URL인지 확인 /// private bool IsValidGoogleSheetUrl(string url) { return !string.IsNullOrEmpty(url) && url.StartsWith("https://script.google.com/macros/") && url.EndsWith("/exec"); } private void GenerateClassFilesPerSheet(string jsonInput) { JObject jsonObject = JObject.Parse(jsonInput); string basePath = $"Assets{_generateFolderPath}"; // enum 후보 수집 Dictionary> enumCandidates = new(); foreach (var jObject in jsonObject) { string className = jObject.Key; if (!IsExistAvailSheets(className)) continue; var items = (JArray)jObject.Value; if (items.Count < 2) continue; for (int i = 1; i < items.Count; i++) { foreach (var property in ((JObject)items[i]).Properties()) { string rawName = property.Name; if (!rawName.Contains("_Enum")) continue; string[] parts = rawName.Split(':'); string enumTypeName = parts.Length > 1 ? parts[1].Replace("_Enum", "") : rawName.Replace("_Enum", ""); string enumValue = NormalizeEnumKey(property.Value.ToString()); if (!enumCandidates.ContainsKey(enumTypeName)) enumCandidates[enumTypeName] = new(); enumCandidates[enumTypeName].Add(enumValue); } } } // EnumTypes.cs 생성 StringBuilder enumCode = new(); enumCode.AppendLine("// "); enumCode.AppendLine("using System;\n"); foreach (var kvp in enumCandidates) { enumCode.AppendLine($"public enum {kvp.Key} \n{{"); enumCode.AppendLine(" None = 0,"); int index = 1; foreach (string value in kvp.Value) { if (!string.IsNullOrWhiteSpace(value) && value != "None") enumCode.AppendLine($" {value} = {index++},"); } enumCode.AppendLine("}\n"); } File.WriteAllText($"{basePath}/EnumTypes.cs", enumCode.ToString()); AssetDatabase.ImportAsset($"{basePath}/EnumTypes.cs"); // 시트별 클래스 파일 생성 foreach (var jObject in jsonObject) { string className = jObject.Key; if (!IsExistAvailSheets(className)) continue; var items = (JArray)jObject.Value; if (items.Count < 2) continue; string dataCode = GenerateDataClassCode(className, items); string soCode = GenerateSoClassCode(className); string dataPath = $"{basePath}/{className}.cs"; string soPath = $"{basePath}/{className}So.cs"; File.WriteAllText(dataPath, dataCode); File.WriteAllText(soPath, soCode); AssetDatabase.ImportAsset(dataPath); AssetDatabase.ImportAsset(soPath); } } private string GenerateSoClassCode(string className) { return $"// File: {className}So.cs\n" + "using System.Collections.Generic;\n" + "using UnityEngine;\n\n" + $"[CreateAssetMenu(fileName = \"{className}So\", menuName = \"GoogleSheet/{className}So\")]\n" + $"public class {className}So : ScriptableObject \n" + $"{{\n public List<{className}> {className}List;\n}}\n"; } private string GenerateDataClassCode(string className, JArray items) { var commentRow = (JObject)items[0]; var sampleRow = (JObject)items[1]; StringBuilder sb = new(); sb.AppendLine("// "); sb.AppendLine("using System;"); sb.AppendLine("using UnityEngine;"); sb.AppendLine("[Serializable]"); sb.AppendLine($"public class {className} \n{{"); int count = sampleRow.Properties().Count(); string[] types = new string[count]; string[] names = new string[count]; string[] tooltips = new string[count]; foreach (JToken item in items.Skip(1)) { int i = 0; foreach (var prop in ((JObject)item).Properties()) { string rawName = prop.Name; string propType = GetCSharpType(prop.Value.Type); string fieldName, enumName; if (rawName.Contains(":") && rawName.EndsWith("_Enum")) { string[] parts = rawName.Split(':'); fieldName = parts[0]; enumName = parts[1].Replace("_Enum", ""); types[i] = enumName; names[i] = fieldName; } else if (rawName.EndsWith("_Enum")) { fieldName = rawName.Replace("_Enum", ""); enumName = fieldName; types[i] = enumName; names[i] = fieldName; } else { types[i] ??= propType; names[i] ??= rawName; } tooltips[i] ??= commentRow.TryGetValue(rawName, out var tip) ? tip.ToString() : ""; i++; } } for (int i = 0; i < count; i++) { if (!string.IsNullOrWhiteSpace(tooltips[i])) { sb.AppendLine($" /// {tooltips[i]}"); sb.AppendLine($" [Tooltip(\"{tooltips[i]}\")]"); } sb.AppendLine($" public {types[i]} {names[i]};\n"); } sb.AppendLine("}"); return sb.ToString(); } private string GetCSharpType(JTokenType jsonType) { switch (jsonType) { case JTokenType.Integer: return "int"; case JTokenType.Float: return "float"; case JTokenType.Boolean: return "bool"; default: return "string"; } } private bool CreateGoogleSheetSo() { JObject jsonObject = JObject.Parse(_json); bool allSuccess = true; foreach (var sheetPair in jsonObject) { string sheetName = sheetPair.Key; if (!IsExistAvailSheets(sheetName)) continue; // 1. 데이터 클래스 및 SO 클래스 타입 찾기 Type dataType = FindTypeByName(sheetName); Type soType = FindTypeByName($"{sheetName}So"); if (dataType == null || soType == null) { Debug.LogError($"[GoogleSheetManager] 타입을 찾을 수 없습니다: {sheetName} 또는 {sheetName}So"); allSuccess = false; continue; } // 2. SO 경로 설정 및 불러오기 / 생성 string soDirectory = $"Assets{_generateFolderPath}/So"; if (!Directory.Exists(soDirectory)) { Directory.CreateDirectory(soDirectory); AssetDatabase.ImportAsset(soDirectory); } string soPath = $"{soDirectory}/{sheetName}So.asset"; ScriptableObject soInstance = AssetDatabase.LoadAssetAtPath(soPath); if (soInstance == null) { soInstance = ScriptableObject.CreateInstance(soType); AssetDatabase.CreateAsset(soInstance, soPath); } GoogleSheetAddressableAutoSetup.AutoRegisterSo(soPath); // 3. 데이터 파싱 IList list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(dataType)); var dataArray = (JArray)sheetPair.Value; for (int i = 1; i < dataArray.Count; i++) // 0번은 주석이므로 제외 { JObject item = (JObject)dataArray[i]; object dataInstance = Activator.CreateInstance(dataType); foreach (var prop in item.Properties()) { string rawName = prop.Name; string fieldName = rawName.Contains(":") ? rawName.Split(':')[0] : rawName; FieldInfo field = dataType.GetField(fieldName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field == null) { Debug.LogWarning($"[GoogleSheetManager] 필드 누락: {dataType.Name}.{fieldName}"); continue; } try { object value; if (field.FieldType.IsEnum) { string enumRaw = prop.Value.ToString(); string formatted = NormalizeEnumKey(enumRaw); value = Enum.TryParse(field.FieldType, formatted, out var parsed) ? parsed : Activator.CreateInstance(field.FieldType); } else { value = Convert.ChangeType(prop.Value.ToString(), field.FieldType); } field.SetValue(dataInstance, value); } catch (Exception e) { Debug.LogWarning( $"[GoogleSheetManager] 값 할당 실패: {fieldName} = {prop.Value} ({field.FieldType}) → {e.Message}"); } } list.Add(dataInstance); } // 4. SO의 필드에 리스트 할당 FieldInfo listField = soType.GetField($"{sheetName}List", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (listField != null) { listField.SetValue(soInstance, list); EditorUtility.SetDirty(soInstance); } else { Debug.LogError($"[GoogleSheetManager] {soType.Name}에 {sheetName}List 필드가 없습니다."); allSuccess = false; } } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log("✅ 시트별 ScriptableObject 생성 및 데이터 반영 완료"); return allSuccess; } private Type FindTypeByName(string name) { return AppDomain.CurrentDomain.GetAssemblies() .SelectMany(a => a.GetTypes()) .FirstOrDefault(t => t.Name == name); } private string NormalizeEnumKey(string input) { string validName = System.Text.RegularExpressions.Regex.Replace(input, @"[^a-zA-Z0-9_]", "_"); if (char.IsDigit(validName[0])) validName = "_" + validName; return char.ToUpper(validName[0]) + validName.Substring(1); } private void OnValidate() { if (_refreshTrigger && !_alreadyCreatedSo) { _refreshTrigger = false; _alreadyCreatedSo = true; DelayedSoCreation(); } } private void DelayedSoCreation() { if (CreateGoogleSheetSo()) { Debug.Log("Fetch done. SO 업데이트 완료"); } else { Debug.LogWarning("[GoogleSheetManager] SO 생성 실패. 수동으로 Fetch를 다시 시도하세요."); } } [BoxGroup("데이터 변경")] [Button("런타임 중 데이터 재적용")] public void ReloadRuntimeData() { StartCoroutine(ReloadRoutine()); } private IEnumerator ReloadRoutine() { _availSheetArray = _availSheets.Split('/'); if (_isAccessGoogleSheet) { var task = LoadDataGoogleSheet(_googleSheetUrl); yield return new WaitUntil(() => task.IsCompleted); _json = task.Result; } else { _json = LoadDataLocalJson(); } if (!string.IsNullOrEmpty(_json)) { CreateGoogleSheetSo(); Debug.Log("런타임 데이터 재적용 완료"); } } public void CreateSoAfterScriptReload() { if (_json != null) { Debug.Log("[GoogleSheetManager] Script Reload 이후 SO 생성 실행"); CreateGoogleSheetSo(); } } #endif }