구글 시트 연동 기능 확장

This commit is contained in:
NTG_DESKTOP 2025-05-12 03:33:52 +09:00
parent 4bde2ee226
commit 624b4373b4
60 changed files with 1646 additions and 546 deletions

View File

@ -285,11 +285,15 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 2631101f894592945a1c50aed7048e66, type: 3}
m_Name:
m_EditorClassIdentifier:
isAccessGoogleSheet: 1
googleSheetUrl: https://script.google.com/macros/s/AKfycbw8TRSl_OuY2S-RX0yvOJi1SqNqoflG0R3pWxk9GC9u_wvGQeuABZc0VH7YJ5lMrAl4/exec
availSheets: Test/Monster
generateFolderPath: /0.Datas/02.Scripts/GenerateGoogleSheet
googleSheetSO: {fileID: 11400000, guid: a205d54a1d0f6b447986268e3fe1d668, type: 2}
_persistent: 1
_isAccessGoogleSheet: 1
_googleSheetUrl: https://script.google.com/macros/s/AKfycbw8TRSl_OuY2S-RX0yvOJi1SqNqoflG0R3pWxk9GC9u_wvGQeuABZc0VH7YJ5lMrAl4/exec
_availSheets: Food/Monster
_generateFolderPath: /0.Datas/02.Scripts/GenerateGoogleSheet/AutoCreated
_lastUpdated: 2025-05-12 03:32:27
_restoreIndex: 0
_editorName:
_refreshTrigger: 1
--- !u!4 &383092898
Transform:
m_ObjectHideFlags: 0
@ -298,12 +302,12 @@ Transform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 383092896}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_Father: {fileID: 1788462582}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &410087039
GameObject:
@ -475,6 +479,38 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1788462581
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1788462582}
m_Layer: 0
m_Name: GameObject
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1788462582
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1788462581}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 383092898}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
@ -482,4 +518,4 @@ SceneRoots:
- {fileID: 330585546}
- {fileID: 410087041}
- {fileID: 832575519}
- {fileID: 383092898}
- {fileID: 1788462582}

View File

@ -1,35 +0,0 @@
using UnityEditor;
using UnityEngine;
public class GoogleSheetDiffWindow : EditorWindow
{
private GoogleSheetDiffResult _diff;
public static void ShowWithDiff(GoogleSheetDiffResult diff)
{
var window = GetWindow<GoogleSheetDiffWindow>("Sheet Diff");
window._diff = diff;
window.Show();
}
private void OnGUI()
{
if (_diff == null)
{
GUILayout.Label("No diff result yet.");
return;
}
GUILayout.Label("\ud83d\udd3c Added", EditorStyles.boldLabel);
foreach (var id in _diff.Added)
GUILayout.Label($" + {id}");
GUILayout.Label("\u270f\ufe0f Modified", EditorStyles.boldLabel);
foreach (var id in _diff.Modified)
GUILayout.Label($" * {id}");
GUILayout.Label("\ud83d\udd3d Removed", EditorStyles.boldLabel);
foreach (var id in _diff.Removed)
GUILayout.Label($" - {id}");
}
}

View File

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: bbe9541a39cf4284babed409264c92ab

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 140c5b11dd5e2c243b7ddc10ddc5b30b
guid: fb4a8c3ebcd8b9040b65650bcb10ed6c
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@ -0,0 +1,14 @@
// <auto-generated>
using System;
public enum Taste
{
None = 0,
Bitter = 1,
Sweet = 2,
Spicy = 3,
Fresh = 4,
Sour = 5,
Salty = 6,
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2714e362cc5f51346a94b6da1f2a68f8

View File

@ -0,0 +1,31 @@
// <auto-generated>
using System;
using UnityEngine;
[Serializable]
public class Food
{
/// <summary>식별ID</summary>
[Tooltip("식별ID")]
public string Id;
/// <summary>이름</summary>
[Tooltip("이름")]
public string Name;
/// <summary>재료1</summary>
[Tooltip("재료1")]
public string Ingredient1;
/// <summary>재료2</summary>
[Tooltip("재료2")]
public string Ingredient2;
/// <summary>맛1</summary>
[Tooltip("맛1")]
public Taste Taste1;
/// <summary>맛2</summary>
[Tooltip("맛2")]
public Taste Taste2;
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8693b42dca1bad640aad102399faa440

View File

@ -0,0 +1,9 @@
// <auto-generated> File: FoodSo.cs
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "FoodSo", menuName = "GoogleSheet/FoodSo")]
public class FoodSo : ScriptableObject
{
public List<Food> FoodList;
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d9a7e15d3d3df5f4a989d1e2e92682e6

View File

@ -0,0 +1,76 @@
{
"$개요": [
{
"": "시트 생성"
}
],
"Food": [
{
"Id": "식별ID",
"Name": "이름",
"Ingredient1": "재료1",
"Ingredient2": "재료2",
"Taste1:Taste_Enum": "맛1",
"Taste2:Taste_Enum": "맛2"
},
{
"Id": "Food001",
"Name": "햇빛수프",
"Ingredient1": "극락쌀",
"Ingredient2": "햇빛당근",
"Taste1:Taste_Enum": "Bitter",
"Taste2:Taste_Enum": "Sweet"
},
{
"Id": "Food002",
"Name": "B",
"Ingredient1": 1,
"Ingredient2": 4,
"Taste1:Taste_Enum": "Spicy",
"Taste2:Taste_Enum": "Bitter"
},
{
"Id": "Food003",
"Name": "C",
"Ingredient1": 2,
"Ingredient2": 5,
"Taste1:Taste_Enum": "Fresh",
"Taste2:Taste_Enum": "None"
},
{
"Id": "Food004",
"Name": "D",
"Ingredient1": 3,
"Ingredient2": 6,
"Taste1:Taste_Enum": "Sour",
"Taste2:Taste_Enum": "Salty"
}
],
"Monster": [
{
"Id": "식별번호",
"Name": "이름",
"T1": "테스트1"
},
{
"Id": "Test001",
"Name": "A",
"T1": 1
},
{
"Id": "Test002",
"Name": "B",
"T1": 2
},
{
"Id": "Test003",
"Name": "C",
"T1": 3
},
{
"Id": "Test004",
"Name": "D",
"T1": 4
}
]
}

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: f01fadb863b05574a964a6fd1b8da2f9
guid: 70eea4fed7f314a4492ac4a97380d4b1
TextScriptImporter:
externalObjects: {}
userData:

View File

@ -0,0 +1,19 @@
// <auto-generated>
using System;
using UnityEngine;
[Serializable]
public class Monster
{
/// <summary>식별번호</summary>
[Tooltip("식별번호")]
public string Id;
/// <summary>이름</summary>
[Tooltip("이름")]
public string Name;
/// <summary>테스트1</summary>
[Tooltip("테스트1")]
public int T1;
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 26a6429de94146a43826ee8860767011

View File

@ -0,0 +1,9 @@
// <auto-generated> File: MonsterSo.cs
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "MonsterSo", menuName = "GoogleSheet/MonsterSo")]
public class MonsterSo : ScriptableObject
{
public List<Monster> MonsterList;
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8a377050a15fa114cb0d0bd8d0f6bafc

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d30016576075fac4e84ec728aaeb2806
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,39 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d9a7e15d3d3df5f4a989d1e2e92682e6, type: 3}
m_Name: FoodSo
m_EditorClassIdentifier:
FoodList:
- Id: Food001
Name: "\uD587\uBE5B\uC218\uD504"
Ingredient1: "\uADF9\uB77D\uC300"
Ingredient2: "\uD587\uBE5B\uB2F9\uADFC"
Taste1: 1
Taste2: 2
- Id: Food002
Name: B
Ingredient1: 1
Ingredient2: 4
Taste1: 3
Taste2: 1
- Id: Food003
Name: C
Ingredient1: 2
Ingredient2: 5
Taste1: 4
Taste2: 0
- Id: Food004
Name: D
Ingredient1: 3
Ingredient2: 6
Taste1: 5
Taste2: 6

View File

@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: a205d54a1d0f6b447986268e3fe1d668
guid: 8f6a170dcd0a88d47939e70db303af14
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000

View File

@ -9,33 +9,16 @@ MonoBehaviour:
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 52c5476425215a341bc2722f655d24fe, type: 3}
m_Name: GoogleSheetSO
m_Script: {fileID: 11500000, guid: 8a377050a15fa114cb0d0bd8d0f6bafc, type: 3}
m_Name: MonsterSo
m_EditorClassIdentifier:
TestList:
- Id: Test001
Name: A
T1: 1
T2: 234
- Id: Test002
Name: B
T1: 2
T2: 2
- Id: Test003
Name: C
T1: 3
T2: 24142
- Id: Test004
Name: D
T1: 4
T2: 2
MonsterList:
- Id: Test001
Name: A
T1: 1
- Id: Test002
Name: B
T1: 5345
T1: 2
- Id: Test003
Name: C
T1: 3

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2a1b86f3356293441bcfca705fff3b85
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 89a023f100586884990e16b137a5b30e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,76 @@
{
"$개요": [
{
"": "시트 생성"
}
],
"Food": [
{
"Id": "식별ID",
"Name": "이름",
"Ingredient1": "재료1",
"Ingredient2": "재료2",
"Taste1:Taste_Enum": "맛1",
"Taste2:Taste_Enum": "맛2"
},
{
"Id": "Food001",
"Name": "햇빛수프",
"Ingredient1": "극락쌀",
"Ingredient2": "햇빛당근",
"Taste1:Taste_Enum": "Bitter",
"Taste2:Taste_Enum": "Sweet"
},
{
"Id": "Food002",
"Name": "B",
"Ingredient1": 1,
"Ingredient2": 11,
"Taste1:Taste_Enum": "Spicy",
"Taste2:Taste_Enum": "Bitter"
},
{
"Id": "Food003",
"Name": "C",
"Ingredient1": 2,
"Ingredient2": 22,
"Taste1:Taste_Enum": "Fresh",
"Taste2:Taste_Enum": "None"
},
{
"Id": "Food004",
"Name": "D",
"Ingredient1": 3,
"Ingredient2": 33,
"Taste1:Taste_Enum": "Sour",
"Taste2:Taste_Enum": "Salty"
}
],
"Monster": [
{
"Id": "식별번호",
"Name": "이름",
"T1": "테스트1"
},
{
"Id": "Test001",
"Name": "A",
"T1": 1
},
{
"Id": "Test002",
"Name": "B",
"T1": 2
},
{
"Id": "Test003",
"Name": "C",
"T1": 3
},
{
"Id": "Test004",
"Name": "D",
"T1": 4
}
]
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5657779a278b799488c5a53a4ae7d508
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,76 @@
{
"$개요": [
{
"": "시트 생성"
}
],
"Food": [
{
"Id": "식별ID",
"Name": "이름",
"Ingredient1": "재료1",
"Ingredient2": "재료2",
"Taste1:Taste_Enum": "맛1",
"Taste2:Taste_Enum": "맛2"
},
{
"Id": "Food001",
"Name": "햇빛수프",
"Ingredient1": "극락쌀",
"Ingredient2": "햇빛당근",
"Taste1:Taste_Enum": "Bitter",
"Taste2:Taste_Enum": "Sweet"
},
{
"Id": "Food002",
"Name": "B",
"Ingredient1": 1,
"Ingredient2": 4,
"Taste1:Taste_Enum": "Spicy",
"Taste2:Taste_Enum": "Bitter"
},
{
"Id": "Food003",
"Name": "C",
"Ingredient1": 2,
"Ingredient2": 5,
"Taste1:Taste_Enum": "Fresh",
"Taste2:Taste_Enum": "None"
},
{
"Id": "Food004",
"Name": "D",
"Ingredient1": 3,
"Ingredient2": 6,
"Taste1:Taste_Enum": "Sour",
"Taste2:Taste_Enum": "Salty"
}
],
"Monster": [
{
"Id": "식별번호",
"Name": "이름",
"T1": "테스트1"
},
{
"Id": "Test001",
"Name": "A",
"T1": 1
},
{
"Id": "Test002",
"Name": "B",
"T1": 2
},
{
"Id": "Test003",
"Name": "C",
"T1": 3
},
{
"Id": "Test004",
"Name": "D",
"T1": 4
}
]
}

View File

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: e5ce26639cbf46447a2b1298c436a485
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 66addae216bafdf40931054ab35bae80
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,710 @@
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;
public class GoogleSheetManager : Singleton<GoogleSheetManager>
{
[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 T So<T>() where T : ScriptableObject
{
#if UNITY_EDITOR
string soName = typeof(T).Name;
string path = $"Assets{Instance._generateFolderPath}/So/{soName}.asset";
T so = AssetDatabase.LoadAssetAtPath<T>(path);
if (so == null)
{
Debug.LogError($"[GoogleSheetManager] {soName}.asset 파일을 찾을 수 없습니다. 먼저 데이터를 생성하세요.");
}
return so;
#else
// 런타임 시에는 Resources 로 로드하거나 Addressables 등을 사용할 수 있음
Debug.LogError("[GoogleSheetManager] 런타임에서 SO를 로드하려면 별도 로딩 로직이 필요합니다.");
return null;
#endif
}
/// <summary>
/// 시트 이름으로 개별 SO를 불러옵니다.
/// 사용 예: GoogleSheetManager.LoadSo<Sheet1So>("Sheet1")
/// </summary>
public static T LoadSo<T>(string sheetName) where T : ScriptableObject
{
string path = $"Assets{Instance._generateFolderPath}/So/{sheetName}So.asset";
T so = AssetDatabase.LoadAssetAtPath<T>(path);
if (so == null)
{
Debug.LogError($"[GoogleSheetManager] 해당 SO를 찾을 수 없습니다: {sheetName}So.asset");
}
return so;
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
[BoxGroup("데이터 변경")]
[Button("데이터 최신화"), EnableIf(nameof(CanFetchData))]
private async void FetchGoogleSheet()
{
_availSheetArray = _availSheets.Split('/');
var prevLog = AssetDatabase.LoadAssetAtPath<GoogleSheetChangeLog>(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);
}
/// <summary>
/// Json 로그 저장
/// </summary>
private void SaveChangeLog(string json)
{
string logsDirectory = Path.GetDirectoryName(ChangeLogPath);
if (!Directory.Exists(logsDirectory))
{
Directory.CreateDirectory(logsDirectory);
AssetDatabase.ImportAsset(logsDirectory);
}
var log = AssetDatabase.LoadAssetAtPath<GoogleSheetChangeLog>(ChangeLogPath);
if (log == null)
{
log = ScriptableObject.CreateInstance<GoogleSheetChangeLog>();
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;
}
/// <summary>
/// Json 백업
/// </summary>
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<GoogleSheetChangeLog>(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<(string Sheet, string Field, int RowIndex, string OldValue, string NewValue)> diffs =
GoogleSheetFetchHelper.CompareJsonDiff(currentJson, restoreJson);
if (diffs.Count > 0)
{
GoogleSheetDiffViewer.ShowWindow(diffs);
Debug.Log("[GoogleSheetManager] 선택한 버전과 현재 버전 간의 변경점을 표시합니다.");
}
else
{
Debug.Log("[GoogleSheetManager] 변경점 없음.");
}
}
[BoxGroup("버전 복구")]
[Button("선택한 버전으로 복구")]
private void RestoreSelectedVersion()
{
var log = AssetDatabase.LoadAssetAtPath<GoogleSheetChangeLog>(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<(string Sheet, string Field, int RowIndex, string OldValue, string NewValue)> 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}]의 버전으로 복원 완료");
}
/// <summary>
/// 버전 로그 드롭다운 함수
/// </summary>
private IEnumerable<ValueDropdownItem<int>> GetVersionOptions()
{
var log = AssetDatabase.LoadAssetAtPath<GoogleSheetChangeLog>(ChangeLogPath);
if (log == null)
yield break;
for (int i = 0; i < log.Logs.Count; i++)
{
yield return new ValueDropdownItem<int>(
$"{i} - {log.Logs[i].Timestamp} by {log.Logs[i].Editor}", i);
}
}
/// <summary>
/// 구글 시트 데이터 읽어오기
/// </summary>
private async Task<string> 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;
}
}
}
/// <summary>
/// jSON 데이터 파일 읽어오기
/// </summary>
private string LoadDataLocalJson()
{
if (File.Exists(JsonPath))
{
return File.ReadAllText(JsonPath);
}
Debug.Log($"Json 파일이 존재하지 않습니다\n{JsonPath}");
return null;
}
/// <summary>
/// 파일 생성 및 비교
/// </summary>
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);
}
/// <summary>
/// 유효한 구글 웹 앱 URL인지 확인
/// </summary>
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<string, HashSet<string>> 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("// <auto-generated>");
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
$"// <auto-generated> 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("// <auto-generated>");
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($" /// <summary>{tooltips[i]}</summary>");
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<ScriptableObject>(soPath);
if (soInstance == null)
{
soInstance = ScriptableObject.CreateInstance(soType);
AssetDatabase.CreateAsset(soInstance, 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
}

View File

@ -0,0 +1,30 @@
// GoogleSheetChangeLog.cs
using System;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "GoogleSheetChangeLog", menuName = "GoogleSheet/ChangeLog", order = 0)]
public class GoogleSheetChangeLog : ScriptableObject
{
[Serializable]
public class LogEntry
{
public string Editor;
public string Timestamp;
[TextArea(5, 20)] public string JsonSnapshot;
}
[SerializeField] private List<LogEntry> _logs = new();
public List<LogEntry> Logs => _logs;
public int MaxLogs = 100;
public void AddEntry(LogEntry entry)
{
if (_logs.Count >= MaxLogs)
{
_logs.RemoveAt(0);
}
_logs.Add(entry);
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47d33f999b3dd2f44a64b3c7b6262376

View File

@ -0,0 +1,67 @@
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
public static class GoogleSheetDiffHelper
{
public static List<(string Sheet, string Field, int RowIndex, string OldValue, string NewValue)> CompareJsonDiff(string oldJson, string newJson)
{
var diffs = new List<(string, string, int, string, string)>();
if (string.IsNullOrEmpty(oldJson) || string.IsNullOrEmpty(newJson))
return diffs;
var oldObj = JObject.Parse(oldJson);
var newObj = JObject.Parse(newJson);
foreach (var sheet in newObj)
{
var sheetName = sheet.Key;
if (!oldObj.TryGetValue(sheetName, out var oldSheetToken))
continue;
var oldArray = oldSheetToken as JArray;
var newArray = sheet.Value as JArray;
// Row-by-row 비교 (1번 줄부터 데이터 시작)
for (int i = 1; i < newArray.Count; i++)
{
if (i >= oldArray.Count)
break;
var newRow = (JObject)newArray[i];
var oldRow = (JObject)oldArray[i];
foreach (var prop in newRow.Properties())
{
var field = prop.Name;
string newValue = prop.Value.ToString();
string oldValue = oldRow.TryGetValue(field, out var oldVal) ? oldVal.ToString() : "";
if (oldValue != newValue)
{
diffs.Add((sheetName, field, i, oldValue, newValue));
}
}
}
}
return diffs;
}
public static string GenerateDiff(string oldJson, string newJson)
{
var diffs = CompareJsonDiff(oldJson, newJson);
if (diffs.Count == 0)
return "No differences found.";
var sb = new System.Text.StringBuilder();
sb.AppendLine("[GoogleSheetManager] 변경된 필드들:");
foreach (var (sheet, field, rowIndex, oldVal, newVal) in diffs)
{
sb.AppendLine($"{sheet} / Row {rowIndex} / {field} : '{oldVal}' → '{newVal}'");
}
return sb.ToString();
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b1cb07de998538442b14e113ff8da86e

View File

@ -0,0 +1,46 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
public class GoogleSheetDiffViewer : EditorWindow
{
private List<(string Sheet, string Field, int RowIndex, string OldValue, string NewValue) > _diffs;
public static void ShowWindow(List<(string Sheet, string Field, int RowIndex, string OldValue, string NewValue) > diffs)
{
var window = GetWindow<GoogleSheetDiffViewer>("Google Sheet 변경점");
window._diffs = diffs;
window.Show();
}
private Vector2 _scroll;
private void OnGUI()
{
if (_diffs == null || _diffs.Count == 0)
{
EditorGUILayout.LabelField("변경 사항이 없습니다.");
return;
}
EditorGUILayout.LabelField("변경된 항목", EditorStyles.boldLabel);
EditorGUILayout.Space();
_scroll = EditorGUILayout.BeginScrollView(_scroll);
EditorGUILayout.BeginVertical("box");
foreach (var diff in _diffs)
{
EditorGUILayout.LabelField($"시트: {diff.Sheet}");
EditorGUILayout.LabelField($"행: {diff.RowIndex + 1}");
EditorGUILayout.LabelField($"필드: {diff.Field}");
EditorGUILayout.LabelField($"기존 값: {diff.OldValue} → 변경 값: {diff.NewValue}", EditorStyles.helpBox);
EditorGUILayout.Space();
}
EditorGUILayout.EndVertical();
EditorGUILayout.EndScrollView();
}
}
#endif

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: feabf00e706fddc49af4692fbfc85632

View File

@ -0,0 +1,45 @@
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEngine;
public static class GoogleSheetFetchHelper
{
public static List<(string Sheet, string Field, int RowIndex, string OldValue, string NewValue)> CompareJsonDiff(string oldJson, string newJson)
{
var result = new List<(string, string, int, string, string)>();
if (string.IsNullOrEmpty(oldJson) || string.IsNullOrEmpty(newJson))
return result;
var oldObj = JObject.Parse(oldJson);
var newObj = JObject.Parse(newJson);
foreach (var sheet in newObj)
{
if (!oldObj.TryGetValue(sheet.Key, out var oldSheetData))
continue;
var newSheetData = (JArray)sheet.Value;
var oldSheetArray = (JArray)oldSheetData;
int minCount = Mathf.Min(oldSheetArray.Count, newSheetData.Count);
for (int i = 1; i < minCount; i++) // i = 1부터: 첫 줄은 주석
{
var oldRow = (JObject)oldSheetArray[i];
var newRow = (JObject)newSheetData[i];
foreach (var prop in newRow.Properties())
{
string oldVal = oldRow.TryGetValue(prop.Name, out var val) ? val.ToString() : "";
string newVal = prop.Value.ToString();
if (oldVal != newVal)
result.Add((sheet.Key, prop.Name, i + 1, oldVal, newVal)); // i+1: 실제 시트에서의 행 번호 (1-based)
}
}
}
return result;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e3062282d3af306458153938166b72ce

View File

@ -0,0 +1,18 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEngine;
public static class GoogleSheetPostProcessor
{
[DidReloadScripts]
private static void OnScriptsReloaded()
{
if (EditorPrefs.GetBool("GoogleSheetManager_ShouldCreateSO", false))
{
EditorPrefs.DeleteKey("GoogleSheetManager_ShouldCreateSO");
GoogleSheetManager.Instance.CreateSoAfterScriptReload();
}
}
}
#endif

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: acef684d9f9959e4dbbf34cd1c9dfb37

View File

@ -1,28 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
/// <summary>You must approach through `GoogleSheetManager.SO<GoogleSheetSO>()`</summary>
public class GoogleSheetSO : ScriptableObject
{
public List<Test> TestList;
public List<Monster> MonsterList;
}
[Serializable]
public class Test
{
public string Id;
public string Name;
public int T1;
public int T2;
}
[Serializable]
public class Monster
{
public string Id;
public string Name;
public int T1;
}

View File

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 52c5476425215a341bc2722f655d24fe

View File

@ -1,50 +0,0 @@
{
"Test": [
{
"Id": "Test001",
"Name": "A",
"T1": 1,
"T2": 234
},
{
"Id": "Test002",
"Name": "B",
"T1": 2,
"T2": 2
},
{
"Id": "Test003",
"Name": "C",
"T1": 3,
"T2": 24142
},
{
"Id": "Test004",
"Name": "D",
"T1": 4,
"T2": 2
}
],
"Monster": [
{
"Id": "Test001",
"Name": "A",
"T1": 1
},
{
"Id": "Test002",
"Name": "B",
"T1": 5345
},
{
"Id": "Test003",
"Name": "C",
"T1": 3
},
{
"Id": "Test004",
"Name": "D",
"T1": 4
}
]
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8f5408d8dd9191c469b5e4717d4da831
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,62 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 47d33f999b3dd2f44a64b3c7b6262376, type: 3}
m_Name: GoogleSheetChangeLog
m_EditorClassIdentifier:
_logs:
- Editor: NTG
Timestamp: 2025-05-12 03:31:27
JsonSnapshot: "{\n \"$\uAC1C\uC694\": [\n {\n \"\": \"\uC2DC\uD2B8 \uC0DD\uC131\"\n
}\n ],\n \"Food\": [\n {\n \"Id\": \"\uC2DD\uBCC4ID\",\n \"Name\":
\"\uC774\uB984\",\n \"Ingredient1\": \"\uC7AC\uB8CC1\",\n \"Ingredient2\":
\"\uC7AC\uB8CC2\",\n \"Taste1:Taste_Enum\": \"\uB9DB1\",\n \"Taste2:Taste_Enum\":
\"\uB9DB2\"\n },\n {\n \"Id\": \"Food001\",\n \"Name\": \"\uD587\uBE5B\uC218\uD504\",\n
\"Ingredient1\": \"\uADF9\uB77D\uC300\",\n \"Ingredient2\": \"\uD587\uBE5B\uB2F9\uADFC\",\n
\"Taste1:Taste_Enum\": \"Bitter\",\n \"Taste2:Taste_Enum\": \"Sweet\"\n
},\n {\n \"Id\": \"Food002\",\n \"Name\": \"B\",\n \"Ingredient1\":
1,\n \"Ingredient2\": 11,\n \"Taste1:Taste_Enum\": \"Spicy\",\n
\"Taste2:Taste_Enum\": \"Bitter\"\n },\n {\n \"Id\": \"Food003\",\n
\"Name\": \"C\",\n \"Ingredient1\": 2,\n \"Ingredient2\": 22,\n
\"Taste1:Taste_Enum\": \"Fresh\",\n \"Taste2:Taste_Enum\": \"None\"\n
},\n {\n \"Id\": \"Food004\",\n \"Name\": \"D\",\n \"Ingredient1\":
3,\n \"Ingredient2\": 33,\n \"Taste1:Taste_Enum\": \"Sour\",\n
\"Taste2:Taste_Enum\": \"Salty\"\n }\n ],\n \"Monster\": [\n {\n
\"Id\": \"\uC2DD\uBCC4\uBC88\uD638\",\n \"Name\": \"\uC774\uB984\",\n
\"T1\": \"\uD14C\uC2A4\uD2B81\"\n },\n {\n \"Id\": \"Test001\",\n
\"Name\": \"A\",\n \"T1\": 1\n },\n {\n \"Id\": \"Test002\",\n
\"Name\": \"B\",\n \"T1\": 2\n },\n {\n \"Id\": \"Test003\",\n
\"Name\": \"C\",\n \"T1\": 3\n },\n {\n \"Id\": \"Test004\",\n
\"Name\": \"D\",\n \"T1\": 4\n }\n ]\n}"
- Editor: NTG
Timestamp: 2025-05-12 03:32:27
JsonSnapshot: "{\n \"$\uAC1C\uC694\": [\n {\n \"\": \"\uC2DC\uD2B8 \uC0DD\uC131\"\n
}\n ],\n \"Food\": [\n {\n \"Id\": \"\uC2DD\uBCC4ID\",\n \"Name\":
\"\uC774\uB984\",\n \"Ingredient1\": \"\uC7AC\uB8CC1\",\n \"Ingredient2\":
\"\uC7AC\uB8CC2\",\n \"Taste1:Taste_Enum\": \"\uB9DB1\",\n \"Taste2:Taste_Enum\":
\"\uB9DB2\"\n },\n {\n \"Id\": \"Food001\",\n \"Name\": \"\uD587\uBE5B\uC218\uD504\",\n
\"Ingredient1\": \"\uADF9\uB77D\uC300\",\n \"Ingredient2\": \"\uD587\uBE5B\uB2F9\uADFC\",\n
\"Taste1:Taste_Enum\": \"Bitter\",\n \"Taste2:Taste_Enum\": \"Sweet\"\n
},\n {\n \"Id\": \"Food002\",\n \"Name\": \"B\",\n \"Ingredient1\":
1,\n \"Ingredient2\": 4,\n \"Taste1:Taste_Enum\": \"Spicy\",\n
\"Taste2:Taste_Enum\": \"Bitter\"\n },\n {\n \"Id\": \"Food003\",\n
\"Name\": \"C\",\n \"Ingredient1\": 2,\n \"Ingredient2\": 5,\n
\"Taste1:Taste_Enum\": \"Fresh\",\n \"Taste2:Taste_Enum\": \"None\"\n
},\n {\n \"Id\": \"Food004\",\n \"Name\": \"D\",\n \"Ingredient1\":
3,\n \"Ingredient2\": 6,\n \"Taste1:Taste_Enum\": \"Sour\",\n
\"Taste2:Taste_Enum\": \"Salty\"\n }\n ],\n \"Monster\": [\n {\n
\"Id\": \"\uC2DD\uBCC4\uBC88\uD638\",\n \"Name\": \"\uC774\uB984\",\n
\"T1\": \"\uD14C\uC2A4\uD2B81\"\n },\n {\n \"Id\": \"Test001\",\n
\"Name\": \"A\",\n \"T1\": 1\n },\n {\n \"Id\": \"Test002\",\n
\"Name\": \"B\",\n \"T1\": 2\n },\n {\n \"Id\": \"Test003\",\n
\"Name\": \"C\",\n \"T1\": 3\n },\n {\n \"Id\": \"Test004\",\n
\"Name\": \"D\",\n \"T1\": 4\n }\n ]\n}"
MaxLogs: 100

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 70eb4ff0fb5057744a569e016975ded0
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,323 +0,0 @@
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 UnityEngine.Serialization;
public class GoogleSheetManager : MonoBehaviour
{
[FormerlySerializedAs("isAccessGoogleSheet")]
[Tooltip("true: google sheet, false: local json")]
[SerializeField] private bool _isAccessGoogleSheet = true;
[FormerlySerializedAs("googleSheetUrl")]
[Tooltip("Google sheet appsscript webapp url")]
[SerializeField] private string _googleSheetUrl;
[FormerlySerializedAs("availSheets")]
[Tooltip("Google sheet avail sheet tabs. seperate `/`. For example `Sheet1/Sheet2`")]
[SerializeField] private string _availSheets = "Sheet1/Sheet2";
[FormerlySerializedAs("generateFolderPath")]
[Tooltip("For example `/GenerateGoogleSheet`")]
[SerializeField] private string _generateFolderPath = "/GenerateGoogleSheet";
[FormerlySerializedAs("googleSheetSO")] [Tooltip("You must approach through `GoogleSheetManager.SO<GoogleSheetSO>()`"), ReadOnly]
public ScriptableObject GoogleSheetSo;
[SerializeField, ReadOnly]
private string _lastUpdated;
private string JsonPath => $"{Application.dataPath}{_generateFolderPath}/GoogleSheetJson.json";
private string ClassPath => $"{Application.dataPath}{_generateFolderPath}/GoogleSheetClass.cs";
private string SoPath => $"Assets{_generateFolderPath}/GoogleSheetSO.asset";
private string[] _availSheetArray;
private string _json;
private bool _refeshTrigger;
private static GoogleSheetManager _instance;
public static T So<T>() where T : ScriptableObject
{
if (GetInstance().GoogleSheetSo == null)
{
Debug.Log($"googleSheetSO is null");
return null;
}
return GetInstance().GoogleSheetSo as T;
}
#if UNITY_EDITOR
[ContextMenu("FetchGoogleSheet")]
private async void FetchGoogleSheet()
{
//Init
_availSheetArray = _availSheets.Split('/');
if (_isAccessGoogleSheet)
{
Debug.Log($"Loading from google sheet..");
_json = await LoadDataGoogleSheet(_googleSheetUrl);
}
else
{
Debug.Log($"Loading from local json..");
_json = LoadDataLocalJson();
}
if (_json == null)
{
Debug.Log("Json is null.");
return;
}
bool isJsonSaved = SaveFileOrSkip(JsonPath, _json);
string allClassCode = GenerateCSharpClass(_json);
bool isClassSaved = SaveFileOrSkip(ClassPath, allClassCode);
if (isJsonSaved || isClassSaved)
{
_refeshTrigger = true;
UnityEditor.AssetDatabase.Refresh();
}
else
{
CreateGoogleSheetSo();
Debug.Log($"Fetch done.");
}
_lastUpdated = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
private async Task<string> 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;
}
}
}
private string LoadDataLocalJson()
{
if (File.Exists(JsonPath))
{
return File.ReadAllText(JsonPath);
}
Debug.Log($"File not exist.\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);
}
private string GenerateCSharpClass(string jsonInput)
{
JObject jsonObject = JObject.Parse(jsonInput);
StringBuilder classCode = new();
// Scriptable Object
classCode.AppendLine("using System;\nusing System.Collections.Generic;\nusing UnityEngine;\n");
classCode.AppendLine("/// <summary>You must approach through `GoogleSheetManager.SO<GoogleSheetSO>()`</summary>");
classCode.AppendLine("public class GoogleSheetSO : ScriptableObject\n{");
foreach (var sheet in jsonObject)
{
string className = sheet.Key;
if (!IsExistAvailSheets(className))
continue;
classCode.AppendLine($"\tpublic List<{className}> {className}List;");
}
classCode.AppendLine("}\n");
// Class
foreach (var jObject in jsonObject)
{
string className = jObject.Key;
if (!IsExistAvailSheets(className))
continue;
var items = (JArray)jObject.Value;
var firstItem = (JObject)items[0];
classCode.AppendLine($"[Serializable]\npublic class {className}\n{{");
// int > float > bool > string
int itemIndex = 0;
int propertyCount = ((JObject)items[0]).Properties().Count();
string[] propertyTypes = new string[propertyCount];
foreach (JToken item in items)
{
itemIndex = 0;
foreach (var property in ((JObject)item).Properties())
{
string propertyType = GetCSharpType(property.Value.Type);
string oldPropertyType = propertyTypes[itemIndex];
if (oldPropertyType == null)
{
propertyTypes[itemIndex] = propertyType;
}
else if (oldPropertyType == "int")
{
if (propertyType == "int") propertyTypes[itemIndex] = "int";
else if (propertyType == "float") propertyTypes[itemIndex] = "float";
else if (propertyType == "bool") propertyTypes[itemIndex] = "string";
else if (propertyType == "string") propertyTypes[itemIndex] = "string";
}
else if (oldPropertyType == "float")
{
if (propertyType == "int") propertyTypes[itemIndex] = "float";
else if (propertyType == "float") propertyTypes[itemIndex] = "float";
else if (propertyType == "bool") propertyTypes[itemIndex] = "string";
else if (propertyType == "string") propertyTypes[itemIndex] = "string";
}
else if (oldPropertyType == "bool")
{
if (propertyType == "int") propertyTypes[itemIndex] = "string";
else if (propertyType == "float") propertyTypes[itemIndex] = "string";
else if (propertyType == "bool") propertyTypes[itemIndex] = "bool";
else if (propertyType == "string") propertyTypes[itemIndex] = "string";
}
itemIndex++;
}
}
itemIndex = 0;
foreach (var property in firstItem.Properties())
{
string propertyName = property.Name;
string propertyType = propertyTypes[itemIndex];
classCode.AppendLine($"\tpublic {propertyType} {propertyName};");
itemIndex++;
}
classCode.AppendLine("}\n");
}
return classCode.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()
{
if (Type.GetType("GoogleSheetSO") == null)
return false;
GoogleSheetSo = ScriptableObject.CreateInstance("GoogleSheetSO");
JObject jsonObject = JObject.Parse(_json);
try
{
foreach (var jObject in jsonObject)
{
string className = jObject.Key;
if (!IsExistAvailSheets(className))
continue;
Type classType = Type.GetType(className);
Type listType = typeof(List<>).MakeGenericType(classType);
IList listInst = (IList)Activator.CreateInstance(listType);
var items = (JArray)jObject.Value;
foreach (var item in items)
{
object classInst = Activator.CreateInstance(classType);
foreach (var property in ((JObject)item).Properties())
{
FieldInfo fieldInfo = classType.GetField(property.Name);
object value = Convert.ChangeType(property.Value.ToString(), fieldInfo.FieldType);
fieldInfo.SetValue(classInst, value);
}
listInst.Add(classInst);
}
GoogleSheetSo.GetType().GetField($"{className}List").SetValue(GoogleSheetSo, listInst);
}
}
catch (Exception e)
{
Debug.LogError($"CreateGoogleSheetSO error: {e.Message}");
}
print("CreateGoogleSheetSO");
UnityEditor.AssetDatabase.CreateAsset(GoogleSheetSo, SoPath);
UnityEditor.AssetDatabase.SaveAssets();
return true;
}
private void OnValidate()
{
if (_refeshTrigger)
{
bool isCompleted = CreateGoogleSheetSo();
if (isCompleted)
{
_refeshTrigger = false;
Debug.Log($"Fetch done.");
}
}
}
#endif
private static GoogleSheetManager GetInstance()
{
if (_instance == null)
{
_instance = FindFirstObjectByType<GoogleSheetManager>();
}
return _instance;
}
}

View File

@ -1,43 +0,0 @@
using System.Collections.Generic;
using System.Linq;
public static class GoogleSheetComparer
{
public static GoogleSheetDiffResult Compare<T>(List<T> oldList, List<T> newList) where T : class
{
var result = new GoogleSheetDiffResult();
var oldDict = oldList.ToDictionary(x => GetId(x));
var newDict = newList.ToDictionary(x => GetId(x));
foreach (var newId in newDict.Keys)
{
if (!oldDict.ContainsKey(newId)) result.Added.Add(newId);
else if (!IsSame(oldDict[newId], newDict[newId])) result.Modified.Add(newId);
}
foreach (var oldId in oldDict.Keys)
{
if (!newDict.ContainsKey(oldId)) result.Removed.Add(oldId);
}
return result;
}
private static string GetId(object obj)
{
var field = obj.GetType().GetField("Id");
return field?.GetValue(obj)?.ToString() ?? "";
}
private static bool IsSame(object a, object b)
{
foreach (var field in a.GetType().GetFields())
{
var valA = field.GetValue(a);
var valB = field.GetValue(b);
if (!Equals(valA, valB)) return false;
}
return true;
}
}

View File

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 78a69b75420adc941ada254e71932c01

View File

@ -1,8 +0,0 @@
using System.Collections.Generic;
public class GoogleSheetDiffResult
{
public List<string> Added = new();
public List<string> Removed = new();
public List<string> Modified = new();
}

View File

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7c8081f426b1b4944985305c853b3062

View File

@ -0,0 +1,101 @@
using JetBrains.Annotations;
using UnityEngine;
[DefaultExecutionOrder(-2)]
public abstract class Singleton<T> : Singleton where T : MonoBehaviour
{
#region Fields
[CanBeNull]
private static T _instance;
[NotNull]
private static readonly object _lock = new();
[SerializeField]
private bool _persistent;
#endregion
#region Properties
[NotNull]
public static T Instance
{
get
{
if (Quitting)
{
if (_instance != null)
return _instance;
var instances = FindObjectsByType<T>(FindObjectsSortMode.None);
var count = instances.Length;
if (count > 0)
{
if (count == 1)
return _instance = instances[0];
//Debug.LogWarning($"[{nameof(Singleton)}<{typeof(T)}>] There should never be more than one {nameof(Singleton)} of type {typeof(T)} in the scene, but {count} were found. The first instance found will be used, and all others will be destroyed.");
for (var i = 1; i < instances.Length; i++)
Destroy(instances[i]);
return _instance = instances[0];
}
//Debug.LogWarning($"[{nameof(Singleton)}<{typeof(T)}>] Instance will not be returned because the application is quitting.");
return null;
}
lock (_lock)
{
if (_instance != null)
return _instance;
var instances = FindObjectsByType<T>(FindObjectsSortMode.None);
var count = instances.Length;
if (count > 0)
{
if (count == 1)
return _instance = instances[0];
Debug.LogWarning($"[{nameof(Singleton)}<{typeof(T)}>] There should never be more than one {nameof(Singleton)} of type {typeof(T)} in the scene, but {count} were found. The first instance found will be used, and all others will be destroyed.");
for (var i = 1; i < instances.Length; i++)
Destroy(instances[i]);
return _instance = instances[0];
}
Debug.Log($"[{nameof(Singleton)}<{typeof(T)}>] An instance is needed in the scene and no existing instances were found, so a new instance will be created.");
return _instance = new GameObject($"({nameof(Singleton)}){typeof(T)}")
.AddComponent<T>();
}
}
}
#endregion
#region Methods
protected virtual void Awake()
{
if (_persistent)
{
if (_instance == null)
{
_instance = this as T;
DontDestroyOnLoad(gameObject);
}
else if (_instance != this)
{
Destroy(gameObject);
}
}
OnAwake();
}
protected virtual void OnAwake() { }
#endregion
}
public abstract class Singleton : MonoBehaviour
{
#region Properties
public static bool Quitting { get; private set; }
#endregion
#region Methods
protected virtual void OnApplicationQuit()
{
Quitting = true;
}
#endregion
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e4108fe16fc07d54d84c51afc87938cc

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f859a6911fd7cbf4680f4923c9e176fb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1b21c32f42ddfd445ab54465dbe714e1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,22 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 1549551891, guid: 74721b9f0af448f5ae2e91102a1a5edd, type: 3}
m_Name: GlobalSerializationConfig
m_EditorClassIdentifier:
HideSerializationCautionaryMessage: 0
HidePrefabCautionaryMessage: 0
HideOdinSerializeAttributeWarningMessages: 0
HideNonSerializedShowInInspectorWarningMessages: 0
buildSerializationFormat: 0
editorSerializationFormat: 2
loggingPolicy: 0
errorHandlingPolicy: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c476d6e989d53f243a19df879920a83d
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -53,6 +53,7 @@ MonoBehaviour:
m_AdditionalLightsShadowResolutionTierHigh: 1024
m_ReflectionProbeBlending: 1
m_ReflectionProbeBoxProjection: 1
m_ReflectionProbeAtlas: 1
m_ShadowDistance: 50
m_ShadowCascadeCount: 4
m_Cascade2Split: 0.25
@ -78,11 +79,11 @@ MonoBehaviour:
m_UseAdaptivePerformance: 1
m_ColorGradingMode: 0
m_ColorGradingLutSize: 32
m_AllowPostProcessAlphaOutput: 0
m_UseFastSRGBLinearConversion: 0
m_SupportDataDrivenLensFlare: 1
m_SupportScreenSpaceLensFlare: 1
m_GPUResidentDrawerMode: 0
m_UseLegacyLightmaps: 0
m_SmallMeshScreenPercentage: 0
m_GPUResidentDrawerEnableOcclusionCullingInCameras: 0
m_ShadowType: 1
@ -100,15 +101,16 @@ MonoBehaviour:
m_Keys: []
m_Values:
m_PrefilteringModeMainLightShadows: 3
m_PrefilteringModeAdditionalLight: 4
m_PrefilteringModeAdditionalLightShadows: 0
m_PrefilteringModeAdditionalLight: 0
m_PrefilteringModeAdditionalLightShadows: 2
m_PrefilterXRKeywords: 1
m_PrefilteringModeForwardPlus: 1
m_PrefilteringModeForwardPlus: 2
m_PrefilteringModeDeferredRendering: 0
m_PrefilteringModeScreenSpaceOcclusion: 1
m_PrefilteringModeScreenSpaceOcclusion: 2
m_PrefilterDebugKeywords: 1
m_PrefilterWriteRenderingLayers: 0
m_PrefilterWriteRenderingLayers: 1
m_PrefilterHDROutput: 1
m_PrefilterAlphaOutput: 1
m_PrefilterSSAODepthNormals: 0
m_PrefilterSSAOSourceDepthLow: 1
m_PrefilterSSAOSourceDepthMedium: 1
@ -120,14 +122,15 @@ MonoBehaviour:
m_PrefilterSSAOSampleCountHigh: 1
m_PrefilterDBufferMRT1: 1
m_PrefilterDBufferMRT2: 1
m_PrefilterDBufferMRT3: 0
m_PrefilterSoftShadowsQualityLow: 0
m_PrefilterSoftShadowsQualityMedium: 0
m_PrefilterSoftShadowsQualityHigh: 0
m_PrefilterDBufferMRT3: 1
m_PrefilterSoftShadowsQualityLow: 1
m_PrefilterSoftShadowsQualityMedium: 1
m_PrefilterSoftShadowsQualityHigh: 1
m_PrefilterSoftShadows: 0
m_PrefilterScreenCoord: 1
m_PrefilterNativeRenderPass: 1
m_PrefilterUseLegacyLightmaps: 0
m_PrefilterBicubicLightmapSampling: 1
m_ShaderVariantLogLevel: 0
m_ShadowCascades: 0
m_Textures:

View File

@ -62,7 +62,20 @@ MonoBehaviour:
- rid: 4427513590311550980
- rid: 4427513590311550981
m_RuntimeSettings:
m_List: []
m_List:
- rid: 6852985685364965378
- rid: 6852985685364965379
- rid: 6852985685364965380
- rid: 6852985685364965381
- rid: 6852985685364965384
- rid: 6852985685364965385
- rid: 6852985685364965392
- rid: 6852985685364965394
- rid: 8712630790384254976
- rid: 4427513590171566080
- rid: 4427513590311550977
- rid: 4427513590311550978
- rid: 4427513590311550980
m_AssetVersion: 8
m_ObsoleteDefaultVolumeProfile: {fileID: 0}
m_RenderingLayerNames:

View File

@ -36,10 +36,8 @@ GraphicsSettings:
- {fileID: 10783, guid: 0000000000000000f000000000000000, type: 0}
m_PreloadedShaders: []
m_PreloadShadersBatchTimeLimit: -1
m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000,
type: 0}
m_CustomRenderPipeline: {fileID: 11400000, guid: 4b83569d67af61e458304325a23e5dfd,
type: 2}
m_SpritesDefaultMaterial: {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0}
m_CustomRenderPipeline: {fileID: 11400000, guid: 4b83569d67af61e458304325a23e5dfd, type: 2}
m_TransparencySortMode: 0
m_TransparencySortAxis: {x: 0, y: 0, z: 1}
m_DefaultRenderingPath: 1
@ -60,8 +58,7 @@ GraphicsSettings:
m_FogKeepExp2: 1
m_AlbedoSwatchInfos: []
m_RenderPipelineGlobalSettingsMap:
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa,
type: 2}
UnityEngine.Rendering.Universal.UniversalRenderPipeline: {fileID: 11400000, guid: 18dc0cd2c080841dea60987a38ce93fa, type: 2}
m_LightsUseLinearIntensity: 1
m_LightsUseColorTemperature: 1
m_LogWhenShaderIsCompiled: 0

View File

@ -12,7 +12,7 @@ PlayerSettings:
targetDevice: 2
useOnDemandResources: 0
accelerometerFrequency: 60
companyName: DefaultCompany
companyName: Capers
productName: ProjectDDD
defaultCursor: {fileID: 0}
cursorHotspot: {x: 0, y: 0}
@ -141,7 +141,8 @@ PlayerSettings:
visionOSBundleVersion: 1.0
tvOSBundleVersion: 1.0
bundleVersion: 0.1.0
preloadedAssets: []
preloadedAssets:
- {fileID: -944628639613478452, guid: 052faaac586de48259a63d0c4782560b, type: 3}
metroInputSource: 0
wsaTransparentSwapchain: 0
m_HolographicPauseOnTrackingLoss: 1
@ -516,7 +517,10 @@ PlayerSettings:
m_Height: 720
m_Kind: 1
m_SubKind:
m_BuildTargetBatching: []
m_BuildTargetBatching:
- m_BuildTarget: Standalone
m_StaticBatching: 1
m_DynamicBatching: 0
m_BuildTargetShaderSettings: []
m_BuildTargetGraphicsJobs: []
m_BuildTargetGraphicsJobMode: []
@ -825,6 +829,7 @@ PlayerSettings:
platformArchitecture: {}
scriptingBackend:
Android: 1
Standalone: 1
il2cppCompilerConfiguration: {}
il2cppCodeGeneration: {}
il2cppStacktraceInformation: {}