diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Prop.meta b/Assets/Scripts/Streamingle/StreamingleControl/Prop.meta new file mode 100644 index 00000000..51d62e7c --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Prop.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ef5f7eb69221f2b498b9a1cc56a4d794 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor.meta b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor.meta new file mode 100644 index 00000000..83ee3505 --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 69ed6044e4ba275409c5c0a75325bf03 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropBrowserWindow.cs b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropBrowserWindow.cs new file mode 100644 index 00000000..aae77b43 --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropBrowserWindow.cs @@ -0,0 +1,1394 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Streamingle.Prop.Editor +{ + /// + /// 개별 프리펩 표시용 데이터 + /// + public class PrefabDisplayItem + { + public string prefabName; // 프리펩 이름 + public string prefabPath; // 프리펩 경로 + public string folderName; // 폴더 이름 + public PropInfo parentProp; // 부모 PropInfo 참조 + public string thumbnailPath; // 썸네일 경로 + + public string DisplayName => prefabName; + } + + /// + /// 프랍 브라우저 에디터 윈도우 + /// + public class PropBrowserWindow : EditorWindow + { + private const string PROP_PATH = "Assets/ResourcesData/Prop"; + private const string DATABASE_PATH = "Assets/Resources/Settings/PropDatabase.asset"; + private const string THUMBNAIL_FOLDER = "Thumbnail"; + private const int THUMBNAIL_SIZE = 128; + private const int THUMBNAIL_CAPTURE_SIZE = 256; + private const int GRID_PADDING = 10; + + private PropDatabase _database; + private List _filteredProps; + private List _displayItems; // 개별 프리펩 표시용 + private Dictionary _thumbnailCache = new Dictionary(); + + private Vector2 _scrollPosition; + private string _searchFilter = ""; + private int _viewMode = 0; // 0: 그리드, 1: 리스트 + + private static readonly string[] VIEW_MODE_OPTIONS = { "그리드", "리스트" }; + private static readonly Color SELECTED_COLOR = new Color(0.3f, 0.6f, 1f, 0.3f); + private static readonly Color HOVER_COLOR = new Color(1f, 1f, 1f, 0.1f); + + [MenuItem("Streamingle/Prop Browser %#p")] + public static void ShowWindow() + { + var window = GetWindow("프랍 브라우저"); + window.minSize = new Vector2(400, 500); + window.Show(); + } + + private void OnEnable() + { + LoadOrCreateDatabase(); + } + + private void OnDisable() + { + ClearThumbnailCache(); + } + + private void OnGUI() + { + DrawToolbar(); + DrawContent(); + } + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + // 새로고침 버튼 + if (GUILayout.Button("새로고침", EditorStyles.toolbarButton, GUILayout.Width(60))) + { + RefreshPropList(); + } + + GUILayout.Space(10); + + // 검색 + EditorGUILayout.LabelField("검색:", GUILayout.Width(35)); + string newSearch = EditorGUILayout.TextField(_searchFilter, EditorStyles.toolbarSearchField, GUILayout.Width(150)); + if (newSearch != _searchFilter) + { + _searchFilter = newSearch; + ApplyFilter(); + } + + if (GUILayout.Button("X", EditorStyles.toolbarButton, GUILayout.Width(20))) + { + _searchFilter = ""; + ApplyFilter(); + GUI.FocusControl(null); + } + + GUILayout.FlexibleSpace(); + + // 뷰 모드 + _viewMode = GUILayout.Toolbar(_viewMode, VIEW_MODE_OPTIONS, EditorStyles.toolbarButton, GUILayout.Width(100)); + + EditorGUILayout.EndHorizontal(); + + // 정보 바 + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + int totalPrefabCount = _database?.props.Sum(p => Math.Max(1, p.prefabPaths.Count)) ?? 0; + int filteredCount = _displayItems?.Count ?? 0; + string countText = string.IsNullOrEmpty(_searchFilter) + ? $"총 {totalPrefabCount}개 프리펩" + : $"{filteredCount}/{totalPrefabCount}개 프리펩"; + EditorGUILayout.LabelField(countText); + + GUILayout.FlexibleSpace(); + + // 썸네일 생성 + if (GUILayout.Button("썸네일 생성", GUILayout.Width(80))) + { + ShowThumbnailMenu(); + } + + // 웹 업로드 + if (GUILayout.Button("웹 업로드", GUILayout.Width(80))) + { + ShowWebUploadMenu(); + } + + // 폴더 열기 + if (GUILayout.Button("폴더", GUILayout.Width(40))) + { + EditorUtility.RevealInFinder(PROP_PATH); + } + + EditorGUILayout.EndHorizontal(); + } + + private void ShowThumbnailMenu() + { + var menu = new GenericMenu(); + menu.AddItem(new GUIContent("모든 프리펩 썸네일 생성 (개별)"), false, () => GenerateAllPrefabThumbnails()); + menu.AddItem(new GUIContent("썸네일 없는 프리펩만 생성"), false, () => GenerateMissingPrefabThumbnails()); + menu.AddSeparator(""); + menu.AddItem(new GUIContent("폴더 단위로 생성 (기존 방식)/모든 폴더"), false, () => GenerateAllThumbnails()); + menu.AddItem(new GUIContent("폴더 단위로 생성 (기존 방식)/선택한 폴더"), false, () => GenerateThumbnailsForSelected()); + menu.ShowAsContext(); + } + + private void DrawContent() + { + if (_database == null || _displayItems == null) + { + EditorGUILayout.HelpBox("데이터베이스를 로드할 수 없습니다. 새로고침을 눌러주세요.", MessageType.Warning); + return; + } + + if (_displayItems.Count == 0) + { + EditorGUILayout.HelpBox("프리펩이 없습니다.", MessageType.Info); + return; + } + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); + + if (_viewMode == 0) + { + DrawGridView(); + } + else + { + DrawListView(); + } + + EditorGUILayout.EndScrollView(); + } + + private void DrawGridView() + { + float windowWidth = position.width - 30; + float itemWidth = THUMBNAIL_SIZE + GRID_PADDING; + float itemHeight = THUMBNAIL_SIZE + 40; // 폴더명 표시를 위해 높이 증가 + int columnsCount = Mathf.Max(1, (int)(windowWidth / itemWidth)); + + int rowCount = Mathf.CeilToInt((float)_displayItems.Count / columnsCount); + float gridHeight = rowCount * itemHeight; + + Rect gridRect = GUILayoutUtility.GetRect(windowWidth, gridHeight); + gridRect.x += GRID_PADDING; + + for (int i = 0; i < _displayItems.Count; i++) + { + int row = i / columnsCount; + int col = i % columnsCount; + + Rect itemRect = new Rect( + gridRect.x + col * itemWidth, + gridRect.y + row * itemHeight, + THUMBNAIL_SIZE, + itemHeight + ); + + DrawGridItem(_displayItems[i], itemRect); + } + } + + private void DrawGridItem(PrefabDisplayItem item, Rect rect) + { + // 호버 효과 + if (rect.Contains(Event.current.mousePosition)) + { + EditorGUI.DrawRect(rect, HOVER_COLOR); + Repaint(); + } + + // 썸네일 + var thumbnailRect = new Rect(rect.x + 2, rect.y + 2, THUMBNAIL_SIZE - 4, THUMBNAIL_SIZE - 4); + var thumbnail = GetThumbnailForItem(item); + + if (thumbnail != null) + { + GUI.DrawTexture(thumbnailRect, thumbnail, ScaleMode.ScaleToFit); + } + else + { + EditorGUI.DrawRect(thumbnailRect, new Color(0.2f, 0.2f, 0.2f)); + + // 프리펩 미리보기 + if (!string.IsNullOrEmpty(item.prefabPath)) + { + var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); + if (prefab != null) + { + var preview = AssetPreview.GetAssetPreview(prefab); + if (preview != null) + { + GUI.DrawTexture(thumbnailRect, preview, ScaleMode.ScaleToFit); + } + else + { + AssetPreview.SetPreviewTextureCacheSize(256); + GUI.Label(thumbnailRect, "Loading...", new GUIStyle(EditorStyles.centeredGreyMiniLabel) { alignment = TextAnchor.MiddleCenter }); + } + } + } + else + { + GUI.Label(thumbnailRect, "No\nPrefab", new GUIStyle(EditorStyles.centeredGreyMiniLabel) { wordWrap = true, alignment = TextAnchor.MiddleCenter }); + } + } + + // 폴더 이름 배지 (프리펩 이름과 다를 경우) + if (!string.IsNullOrEmpty(item.folderName) && item.folderName != item.prefabName) + { + var folderBadgeRect = new Rect(rect.x + 4, rect.y + THUMBNAIL_SIZE - 18, THUMBNAIL_SIZE - 8, 14); + EditorGUI.DrawRect(folderBadgeRect, new Color(0, 0, 0, 0.7f)); + GUI.Label(folderBadgeRect, item.folderName, new GUIStyle(EditorStyles.miniLabel) { + alignment = TextAnchor.MiddleCenter, + normal = { textColor = new Color(1, 1, 1, 0.9f) }, + fontSize = 9, + clipping = TextClipping.Clip + }); + } + + // 프리펩 이름 + var labelRect = new Rect(rect.x, rect.y + THUMBNAIL_SIZE, THUMBNAIL_SIZE, 35); + GUI.Label(labelRect, item.prefabName, new GUIStyle(EditorStyles.miniLabel) { + alignment = TextAnchor.UpperCenter, + wordWrap = true, + clipping = TextClipping.Clip + }); + + // 클릭 이벤트 + if (Event.current.type == EventType.MouseDown && rect.Contains(Event.current.mousePosition)) + { + if (Event.current.clickCount == 2) + { + // 더블클릭: 프리펩 선택 + SelectPrefabInProject(item); + } + else if (Event.current.button == 0) + { + // 싱글클릭: 드래그 시작 + HandleDragStartForItem(item); + } + else if (Event.current.button == 1) + { + // 우클릭: 컨텍스트 메뉴 + ShowContextMenuForItem(item); + } + Event.current.Use(); + } + } + + private void DrawListView() + { + foreach (var item in _displayItems) + { + DrawListItem(item); + } + } + + private void DrawListItem(PrefabDisplayItem item) + { + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + + // 썸네일 (작게) + var thumbnail = GetThumbnailForItem(item); + var thumbRect = GUILayoutUtility.GetRect(50, 50, GUILayout.Width(50), GUILayout.Height(50)); + + if (thumbnail != null) + { + GUI.DrawTexture(thumbRect, thumbnail, ScaleMode.ScaleToFit); + } + else + { + EditorGUI.DrawRect(thumbRect, new Color(0.2f, 0.2f, 0.2f)); + + // 프리펩 미리보기 + if (!string.IsNullOrEmpty(item.prefabPath)) + { + var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); + if (prefab != null) + { + var preview = AssetPreview.GetAssetPreview(prefab); + if (preview != null) + { + GUI.DrawTexture(thumbRect, preview, ScaleMode.ScaleToFit); + } + } + } + } + + GUILayout.Space(10); + + // 프리펩 정보 + EditorGUILayout.BeginVertical(); + + EditorGUILayout.LabelField(item.prefabName, EditorStyles.boldLabel); + + // 폴더 이름 (다르면 표시) + if (!string.IsNullOrEmpty(item.folderName) && item.folderName != item.prefabName) + { + EditorGUILayout.LabelField($"폴더: {item.folderName}", EditorStyles.miniLabel); + } + + EditorGUILayout.EndVertical(); + + GUILayout.FlexibleSpace(); + + // 버튼들 + EditorGUILayout.BeginVertical(); + + if (GUILayout.Button("선택", GUILayout.Width(50))) + { + SelectPrefabInProject(item); + } + + if (GUILayout.Button("...", GUILayout.Width(50))) + { + ShowContextMenuForItem(item); + } + + EditorGUILayout.EndVertical(); + + EditorGUILayout.EndHorizontal(); + + // 드래그 처리 + var lastRect = GUILayoutUtility.GetLastRect(); + if (Event.current.type == EventType.MouseDown && lastRect.Contains(Event.current.mousePosition)) + { + if (Event.current.button == 0) + { + HandleDragStartForItem(item); + } + } + } + + private void HandleDragStart(PropInfo propInfo) + { + if (propInfo.prefabPaths.Count == 0) return; + + var prefab = AssetDatabase.LoadAssetAtPath(propInfo.MainPrefabPath); + if (prefab == null) return; + + DragAndDrop.PrepareStartDrag(); + DragAndDrop.objectReferences = new UnityEngine.Object[] { prefab }; + DragAndDrop.paths = new string[] { propInfo.MainPrefabPath }; + DragAndDrop.StartDrag(propInfo.propName); + } + + private void HandleDragStartForItem(PrefabDisplayItem item) + { + if (string.IsNullOrEmpty(item.prefabPath)) return; + + var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); + if (prefab == null) return; + + DragAndDrop.PrepareStartDrag(); + DragAndDrop.objectReferences = new UnityEngine.Object[] { prefab }; + DragAndDrop.paths = new string[] { item.prefabPath }; + DragAndDrop.StartDrag(item.prefabName); + } + + private void SelectPrefabInProject(PrefabDisplayItem item) + { + if (!string.IsNullOrEmpty(item.prefabPath)) + { + var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); + if (prefab != null) + { + Selection.activeObject = prefab; + EditorGUIUtility.PingObject(prefab); + return; + } + } + + // 프리펩이 없으면 폴더 선택 + if (item.parentProp != null) + { + var folder = AssetDatabase.LoadAssetAtPath(item.parentProp.folderPath); + if (folder != null) + { + Selection.activeObject = folder; + EditorGUIUtility.PingObject(folder); + } + } + } + + private void ShowContextMenuForItem(PrefabDisplayItem item) + { + var menu = new GenericMenu(); + + menu.AddItem(new GUIContent("프로젝트에서 선택"), false, () => SelectPrefabInProject(item)); + + if (item.parentProp != null) + { + menu.AddItem(new GUIContent("폴더 열기"), false, () => RevealInFinder(item.parentProp)); + } + + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("씬에 배치"), false, () => InstantiatePrefabFromItem(item)); + + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("이 프리펩 썸네일 생성"), false, () => GenerateThumbnailForItem(item)); + + menu.ShowAsContext(); + } + + private void InstantiatePrefabFromItem(PrefabDisplayItem item) + { + if (string.IsNullOrEmpty(item.prefabPath)) + { + EditorUtility.DisplayDialog("오류", "프리펩이 없습니다.", "확인"); + return; + } + + var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); + if (prefab == null) + { + EditorUtility.DisplayDialog("오류", "프리펩을 로드할 수 없습니다.", "확인"); + return; + } + + var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject; + if (instance != null) + { + if (SceneView.lastActiveSceneView != null) + { + instance.transform.position = SceneView.lastActiveSceneView.pivot; + } + + Selection.activeGameObject = instance; + Undo.RegisterCreatedObjectUndo(instance, "Instantiate Prop"); + } + } + + private Texture2D GetThumbnailForItem(PrefabDisplayItem item) + { + if (string.IsNullOrEmpty(item.thumbnailPath)) + return null; + + if (_thumbnailCache.TryGetValue(item.thumbnailPath, out var cached)) + return cached; + + var texture = AssetDatabase.LoadAssetAtPath(item.thumbnailPath); + if (texture != null) + { + _thumbnailCache[item.thumbnailPath] = texture; + } + + return texture; + } + + private void ShowContextMenu(PropInfo propInfo) + { + var menu = new GenericMenu(); + + menu.AddItem(new GUIContent("프로젝트에서 선택"), false, () => SelectPropInProject(propInfo)); + menu.AddItem(new GUIContent("폴더 열기"), false, () => RevealInFinder(propInfo)); + + menu.AddSeparator(""); + + // 프리펩 목록 + if (propInfo.prefabPaths.Count > 0) + { + foreach (var prefabPath in propInfo.prefabPaths) + { + string prefabName = Path.GetFileNameWithoutExtension(prefabPath); + menu.AddItem(new GUIContent($"프리펩/{prefabName}"), false, () => + { + var prefab = AssetDatabase.LoadAssetAtPath(prefabPath); + if (prefab != null) + { + Selection.activeObject = prefab; + EditorGUIUtility.PingObject(prefab); + } + }); + } + } + else + { + menu.AddDisabledItem(new GUIContent("프리펩 없음")); + } + + menu.AddSeparator(""); + + // 모델 목록 + if (propInfo.modelPaths.Count > 0) + { + foreach (var modelPath in propInfo.modelPaths) + { + string modelName = Path.GetFileName(modelPath); + menu.AddItem(new GUIContent($"모델/{modelName}"), false, () => + { + var model = AssetDatabase.LoadAssetAtPath(modelPath); + if (model != null) + { + Selection.activeObject = model; + EditorGUIUtility.PingObject(model); + } + }); + } + } + + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("씬에 배치"), false, () => InstantiatePrefab(propInfo)); + + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("썸네일 생성"), false, () => GenerateThumbnail(propInfo)); + + menu.ShowAsContext(); + } + + private void SelectPropInProject(PropInfo propInfo) + { + if (propInfo.prefabPaths.Count > 0) + { + var prefab = AssetDatabase.LoadAssetAtPath(propInfo.MainPrefabPath); + if (prefab != null) + { + Selection.activeObject = prefab; + EditorGUIUtility.PingObject(prefab); + return; + } + } + + // 프리펩이 없으면 폴더 선택 + var folder = AssetDatabase.LoadAssetAtPath(propInfo.folderPath); + if (folder != null) + { + Selection.activeObject = folder; + EditorGUIUtility.PingObject(folder); + } + } + + private void RevealInFinder(PropInfo propInfo) + { + EditorUtility.RevealInFinder(propInfo.folderPath); + } + + private void InstantiatePrefab(PropInfo propInfo) + { + if (propInfo.prefabPaths.Count == 0) + { + EditorUtility.DisplayDialog("오류", "프리펩이 없습니다.", "확인"); + return; + } + + var prefab = AssetDatabase.LoadAssetAtPath(propInfo.MainPrefabPath); + if (prefab == null) + { + EditorUtility.DisplayDialog("오류", "프리펩을 로드할 수 없습니다.", "확인"); + return; + } + + var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject; + if (instance != null) + { + // 씬 뷰 중앙에 배치 + if (SceneView.lastActiveSceneView != null) + { + instance.transform.position = SceneView.lastActiveSceneView.pivot; + } + + Selection.activeGameObject = instance; + Undo.RegisterCreatedObjectUndo(instance, "Instantiate Prop"); + } + } + + private Texture2D GetThumbnail(PropInfo propInfo) + { + if (string.IsNullOrEmpty(propInfo.thumbnailPath)) + return null; + + if (_thumbnailCache.TryGetValue(propInfo.thumbnailPath, out var cached)) + return cached; + + var texture = AssetDatabase.LoadAssetAtPath(propInfo.thumbnailPath); + if (texture != null) + { + _thumbnailCache[propInfo.thumbnailPath] = texture; + } + + return texture; + } + + private void ClearThumbnailCache() + { + _thumbnailCache.Clear(); + } + + private void LoadOrCreateDatabase() + { + _database = AssetDatabase.LoadAssetAtPath(DATABASE_PATH); + + if (_database == null) + { + // 폴더 생성 + if (!AssetDatabase.IsValidFolder("Assets/Resources")) + AssetDatabase.CreateFolder("Assets", "Resources"); + if (!AssetDatabase.IsValidFolder("Assets/Resources/Settings")) + AssetDatabase.CreateFolder("Assets/Resources", "Settings"); + + _database = CreateInstance(); + AssetDatabase.CreateAsset(_database, DATABASE_PATH); + AssetDatabase.SaveAssets(); + } + + RefreshPropList(); + } + + private void RefreshPropList() + { + if (_database == null) return; + + _database.props.Clear(); + + if (!Directory.Exists(PROP_PATH)) + { + UnityEngine.Debug.LogWarning($"Prop 폴더가 없습니다: {PROP_PATH}"); + ApplyFilter(); + return; + } + + // Prop 하위 폴더들 검색 (Glb, Prop Prefab 제외) + var propFolders = Directory.GetDirectories(PROP_PATH) + .Where(f => !f.EndsWith("Glb") && !f.EndsWith("Prop Prefab")) + .ToList(); + + foreach (var folderPath in propFolders) + { + string propName = Path.GetFileName(folderPath); + string assetPath = folderPath.Replace("\\", "/"); + + // Assets/ 형식으로 변환 + int assetsIndex = assetPath.IndexOf("Assets/"); + if (assetsIndex >= 0) + { + assetPath = assetPath.Substring(assetsIndex); + } + + var propInfo = new PropInfo + { + propName = propName, + folderPath = assetPath + }; + + // 파일 스캔 + ScanPropFiles(propInfo, folderPath); + + _database.props.Add(propInfo); + } + + // 이름순 정렬 + _database.props = _database.props.OrderBy(p => p.propName).ToList(); + + EditorUtility.SetDirty(_database); + AssetDatabase.SaveAssets(); + + ApplyFilter(); + + UnityEngine.Debug.Log($"[PropBrowser] {_database.props.Count}개 프랍 스캔 완료"); + } + + private void ScanPropFiles(PropInfo propInfo, string folderPath) + { + var allFiles = Directory.GetFiles(folderPath, "*.*", SearchOption.AllDirectories) + .Where(f => !f.EndsWith(".meta")) + .ToList(); + + foreach (var file in allFiles) + { + string ext = Path.GetExtension(file).ToLower(); + string assetPath = file.Replace("\\", "/"); + + int assetsIndex = assetPath.IndexOf("Assets/"); + if (assetsIndex >= 0) + { + assetPath = assetPath.Substring(assetsIndex); + } + + if (ext == ".prefab") + { + propInfo.prefabPaths.Add(assetPath); + } + else if (ext == ".glb" || ext == ".fbx" || ext == ".obj") + { + propInfo.modelPaths.Add(assetPath); + } + else if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".tga" || ext == ".psd") + { + propInfo.textureCount++; + + // 썸네일 찾기 (thumbnail 또는 _thumb 포함) + string fileName = Path.GetFileNameWithoutExtension(file).ToLower(); + if (fileName.Contains("thumbnail") || fileName.Contains("_thumb")) + { + propInfo.thumbnailPath = assetPath; + } + } + else if (ext == ".mat") + { + propInfo.materialCount++; + } + } + + // 프리펩 이름순 정렬 + propInfo.prefabPaths = propInfo.prefabPaths.OrderBy(p => Path.GetFileName(p)).ToList(); + } + + private void ApplyFilter() + { + if (_database == null) + { + _filteredProps = new List(); + _displayItems = new List(); + return; + } + + // 기존 필터링 + if (string.IsNullOrEmpty(_searchFilter)) + { + _filteredProps = new List(_database.props); + } + else + { + string filter = _searchFilter.ToLower(); + _filteredProps = _database.props + .Where(p => p.propName.ToLower().Contains(filter) || + p.prefabPaths.Any(pp => Path.GetFileNameWithoutExtension(pp).ToLower().Contains(filter))) + .ToList(); + } + + // 개별 프리펩 단위로 표시 아이템 생성 + _displayItems = new List(); + + foreach (var prop in _filteredProps) + { + if (prop.prefabPaths.Count > 0) + { + // 각 프리펩을 개별 아이템으로 추가 + foreach (var prefabPath in prop.prefabPaths) + { + string prefabName = Path.GetFileNameWithoutExtension(prefabPath); + + // 검색 필터 추가 적용 + if (!string.IsNullOrEmpty(_searchFilter)) + { + string filter = _searchFilter.ToLower(); + if (!prefabName.ToLower().Contains(filter) && !prop.propName.ToLower().Contains(filter)) + continue; + } + + // 개별 프리펩 썸네일 경로 찾기 + string prefabThumbnailPath = $"{prop.folderPath}/{THUMBNAIL_FOLDER}/{prefabName}_thumbnail.png"; + string prefabThumbnailFullPath = prefabThumbnailPath.Replace("Assets/", Application.dataPath + "/"); + + // 개별 썸네일이 없으면 폴더 썸네일 사용 + string thumbnailToUse = File.Exists(prefabThumbnailFullPath) ? prefabThumbnailPath : prop.thumbnailPath; + + _displayItems.Add(new PrefabDisplayItem + { + prefabName = prefabName, + prefabPath = prefabPath, + folderName = prop.propName, + parentProp = prop, + thumbnailPath = thumbnailToUse + }); + } + } + else + { + // 프리펩이 없는 폴더도 표시 + _displayItems.Add(new PrefabDisplayItem + { + prefabName = prop.propName, + prefabPath = null, + folderName = prop.propName, + parentProp = prop, + thumbnailPath = prop.thumbnailPath + }); + } + } + + // 이름순 정렬 + _displayItems = _displayItems.OrderBy(d => d.prefabName).ToList(); + } + + #region Thumbnail Generation + + /// + /// 개별 프리펩에 대한 썸네일 생성 + /// + private bool GenerateThumbnailForItem(PrefabDisplayItem item) + { + if (string.IsNullOrEmpty(item.prefabPath) || item.parentProp == null) + { + UnityEngine.Debug.LogWarning($"[PropBrowser] {item.prefabName}: 프리펩 경로가 없습니다."); + return false; + } + + var prefab = AssetDatabase.LoadAssetAtPath(item.prefabPath); + if (prefab == null) + { + UnityEngine.Debug.LogWarning($"[PropBrowser] {item.prefabName}: 프리펩을 로드할 수 없습니다."); + return false; + } + + // 썸네일 폴더 경로 + string thumbnailFolderPath = $"{item.parentProp.folderPath}/{THUMBNAIL_FOLDER}"; + string thumbnailFileName = $"{item.prefabName}_thumbnail.png"; + string thumbnailPath = $"{thumbnailFolderPath}/{thumbnailFileName}"; + + // 폴더 생성 + if (!AssetDatabase.IsValidFolder(thumbnailFolderPath)) + { + AssetDatabase.CreateFolder(item.parentProp.folderPath, THUMBNAIL_FOLDER); + } + + var previewTexture = GeneratePreviewTexture(prefab); + if (previewTexture == null) + { + UnityEngine.Debug.LogWarning($"[PropBrowser] {item.prefabName}: 미리보기를 생성할 수 없습니다."); + return false; + } + + try + { + byte[] pngData = previewTexture.EncodeToPNG(); + string fullPath = thumbnailPath.Replace("Assets/", Application.dataPath + "/"); + + string directory = Path.GetDirectoryName(fullPath); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllBytes(fullPath, pngData); + DestroyImmediate(previewTexture); + + AssetDatabase.ImportAsset(thumbnailPath); + + // item의 썸네일 경로 업데이트 + item.thumbnailPath = thumbnailPath; + + UnityEngine.Debug.Log($"[PropBrowser] {item.prefabName}: 썸네일 생성 완료"); + return true; + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"[PropBrowser] {item.prefabName}: 썸네일 저장 실패 - {ex.Message}"); + return false; + } + } + + /// + /// 모든 프리펩에 대해 개별 썸네일 생성 + /// + private void GenerateAllPrefabThumbnails() + { + // 모든 프리펩 아이템 수집 (필터 없이) + var allItems = new List(); + foreach (var prop in _database.props) + { + foreach (var prefabPath in prop.prefabPaths) + { + allItems.Add(new PrefabDisplayItem + { + prefabName = Path.GetFileNameWithoutExtension(prefabPath), + prefabPath = prefabPath, + folderName = prop.propName, + parentProp = prop + }); + } + } + + if (allItems.Count == 0) + { + EditorUtility.DisplayDialog("알림", "생성할 프리펩이 없습니다.", "확인"); + return; + } + + if (!EditorUtility.DisplayDialog("확인", + $"모든 프리펩 ({allItems.Count}개)의 썸네일을 개별 생성합니다.\n계속하시겠습니까?", + "생성", "취소")) + { + return; + } + + GeneratePrefabThumbnails(allItems, "모든 프리펩"); + } + + /// + /// 썸네일이 없는 프리펩만 개별 썸네일 생성 + /// + private void GenerateMissingPrefabThumbnails() + { + var itemsWithoutThumbnail = new List(); + + foreach (var prop in _database.props) + { + foreach (var prefabPath in prop.prefabPaths) + { + string prefabName = Path.GetFileNameWithoutExtension(prefabPath); + string expectedThumbnailPath = $"{prop.folderPath}/{THUMBNAIL_FOLDER}/{prefabName}_thumbnail.png"; + string fullPath = expectedThumbnailPath.Replace("Assets/", Application.dataPath + "/"); + + if (!File.Exists(fullPath)) + { + itemsWithoutThumbnail.Add(new PrefabDisplayItem + { + prefabName = prefabName, + prefabPath = prefabPath, + folderName = prop.propName, + parentProp = prop + }); + } + } + } + + if (itemsWithoutThumbnail.Count == 0) + { + EditorUtility.DisplayDialog("알림", "모든 프리펩에 썸네일이 있습니다.", "확인"); + return; + } + + GeneratePrefabThumbnails(itemsWithoutThumbnail, $"썸네일 없는 {itemsWithoutThumbnail.Count}개 프리펩"); + } + + /// + /// PrefabDisplayItem 리스트에 대해 썸네일 일괄 생성 + /// + private void GeneratePrefabThumbnails(List items, string description) + { + int successCount = 0; + int failCount = 0; + + try + { + for (int i = 0; i < items.Count; i++) + { + var item = items[i]; + + EditorUtility.DisplayProgressBar("썸네일 생성 중", + $"{item.prefabName} ({i + 1}/{items.Count})", + (float)i / items.Count); + + if (GenerateThumbnailForItem(item)) + { + successCount++; + } + else + { + failCount++; + } + } + } + finally + { + EditorUtility.ClearProgressBar(); + } + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + ClearThumbnailCache(); + ApplyFilter(); // displayItems 다시 생성하여 썸네일 경로 반영 + Repaint(); + + EditorUtility.DisplayDialog("완료", + $"{description} 썸네일 생성 완료\n성공: {successCount}개\n실패: {failCount}개", + "확인"); + } + + private void GenerateThumbnailsForSelected() + { + // 현재 선택된 프랍 (Selection에서) + var selectedObjects = Selection.objects; + var propsToGenerate = new List(); + + foreach (var obj in selectedObjects) + { + string path = AssetDatabase.GetAssetPath(obj); + if (string.IsNullOrEmpty(path)) continue; + + var prop = _database.props.Find(p => path.StartsWith(p.folderPath)); + if (prop != null && !propsToGenerate.Contains(prop)) + { + propsToGenerate.Add(prop); + } + } + + if (propsToGenerate.Count == 0) + { + EditorUtility.DisplayDialog("알림", "선택된 프랍이 없습니다.\n프로젝트 창에서 프랍 폴더나 프리펩을 선택해주세요.", "확인"); + return; + } + + GenerateThumbnails(propsToGenerate, $"선택한 {propsToGenerate.Count}개 프랍"); + } + + private void GenerateAllThumbnails() + { + if (!EditorUtility.DisplayDialog("확인", + $"모든 프랍 ({_database.props.Count}개)의 썸네일을 생성합니다.\n계속하시겠습니까?", + "생성", "취소")) + { + return; + } + + GenerateThumbnails(_database.props, "모든 프랍"); + } + + private void GenerateMissingThumbnails() + { + var propsWithoutThumbnail = _database.props + .Where(p => string.IsNullOrEmpty(p.thumbnailPath) || + !File.Exists(p.thumbnailPath.Replace("Assets/", Application.dataPath + "/"))) + .ToList(); + + if (propsWithoutThumbnail.Count == 0) + { + EditorUtility.DisplayDialog("알림", "모든 프랍에 썸네일이 있습니다.", "확인"); + return; + } + + GenerateThumbnails(propsWithoutThumbnail, $"썸네일 없는 {propsWithoutThumbnail.Count}개 프랍"); + } + + private void GenerateThumbnails(List props, string description) + { + int successCount = 0; + int failCount = 0; + + try + { + for (int i = 0; i < props.Count; i++) + { + var prop = props[i]; + + EditorUtility.DisplayProgressBar("썸네일 생성 중", + $"{prop.propName} ({i + 1}/{props.Count})", + (float)i / props.Count); + + if (GenerateThumbnail(prop)) + { + successCount++; + } + else + { + failCount++; + } + } + } + finally + { + EditorUtility.ClearProgressBar(); + } + + EditorUtility.SetDirty(_database); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + ClearThumbnailCache(); + Repaint(); + + EditorUtility.DisplayDialog("완료", + $"{description} 썸네일 생성 완료\n성공: {successCount}개\n실패: {failCount}개", + "확인"); + } + + private bool GenerateThumbnail(PropInfo propInfo) + { + if (propInfo.prefabPaths.Count == 0) + { + UnityEngine.Debug.LogWarning($"[PropBrowser] {propInfo.propName}: 프리펩이 없어 썸네일을 생성할 수 없습니다."); + return false; + } + + // 프리펩 로드 + var prefab = AssetDatabase.LoadAssetAtPath(propInfo.MainPrefabPath); + if (prefab == null) + { + UnityEngine.Debug.LogWarning($"[PropBrowser] {propInfo.propName}: 프리펩을 로드할 수 없습니다."); + return false; + } + + // 썸네일 폴더 경로 + string thumbnailFolderPath = $"{propInfo.folderPath}/{THUMBNAIL_FOLDER}"; + string thumbnailFileName = $"{propInfo.propName}_thumbnail.png"; + string thumbnailPath = $"{thumbnailFolderPath}/{thumbnailFileName}"; + + // 폴더 생성 + if (!AssetDatabase.IsValidFolder(thumbnailFolderPath)) + { + AssetDatabase.CreateFolder(propInfo.folderPath, THUMBNAIL_FOLDER); + } + + // 씬에서 직접 렌더링하여 썸네일 생성 (씬 조명 활용) + var previewTexture = GeneratePreviewTexture(prefab); + + if (previewTexture == null) + { + UnityEngine.Debug.LogWarning($"[PropBrowser] {propInfo.propName}: 미리보기를 생성할 수 없습니다."); + return false; + } + + // 텍스처 저장 + try + { + // PNG로 저장 + byte[] pngData = previewTexture.EncodeToPNG(); + string fullPath = thumbnailPath.Replace("Assets/", Application.dataPath + "/"); + + string directory = Path.GetDirectoryName(fullPath); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllBytes(fullPath, pngData); + + DestroyImmediate(previewTexture); + + // 에셋 임포트 + AssetDatabase.ImportAsset(thumbnailPath); + + // PropInfo 업데이트 + propInfo.thumbnailPath = thumbnailPath; + + UnityEngine.Debug.Log($"[PropBrowser] {propInfo.propName}: 썸네일 생성 완료"); + return true; + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"[PropBrowser] {propInfo.propName}: 썸네일 저장 실패 - {ex.Message}"); + return false; + } + } + + private Texture2D GeneratePreviewTexture(GameObject prefab) + { + // 씬에서 프리펩을 소환하여 씬 조명을 활용 + var tempInstance = PrefabUtility.InstantiatePrefab(prefab) as GameObject; + if (tempInstance == null) + { + tempInstance = Instantiate(prefab); + } + + // 썸네일 촬영용 임시 위치 설정 (씬 외곽) + Vector3 capturePosition = new Vector3(10000f, 10000f, 10000f); + tempInstance.transform.position = capturePosition; + tempInstance.name = "[ThumbnailCapture_Temp]"; + + try + { + // 바운드 계산 - 모든 렌더러 포함 + Bounds bounds = CalculateTotalBounds(tempInstance); + if (bounds.size == Vector3.zero) + { + DestroyImmediate(tempInstance); + return null; + } + + // 카메라 설정 + var cameraGo = new GameObject("[ThumbnailCamera_Temp]"); + var camera = cameraGo.AddComponent(); + camera.backgroundColor = new Color(0.15f, 0.15f, 0.15f, 0f); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.orthographic = false; + camera.fieldOfView = 30f; // 좁은 FOV로 왜곡 최소화 + camera.nearClipPlane = 0.01f; + camera.farClipPlane = 10000f; + + // 카메라 위치 계산 - 바운더리 기반으로 전체가 보이도록 + PositionCameraToFitBounds(camera, bounds); + + // 렌더 텍스처 + var rt = new RenderTexture(THUMBNAIL_CAPTURE_SIZE, THUMBNAIL_CAPTURE_SIZE, 24, RenderTextureFormat.ARGB32); + rt.antiAliasing = 4; // 안티앨리어싱 추가 + camera.targetTexture = rt; + + // 씬 조명이 없는 경우를 대비한 보조 조명 + GameObject backupLightGo = null; + var existingLights = FindObjectsByType(FindObjectsSortMode.None); + bool hasSceneLight = existingLights.Any(l => l.enabled && l.gameObject.activeInHierarchy && l.type == LightType.Directional); + + if (!hasSceneLight) + { + // 씬에 디렉셔널 라이트가 없으면 임시 조명 추가 + backupLightGo = new GameObject("[ThumbnailLight_Temp]"); + var light = backupLightGo.AddComponent(); + light.type = LightType.Directional; + light.transform.rotation = Quaternion.Euler(50, -30, 0); + light.intensity = 1.5f; + light.color = new Color(1f, 0.98f, 0.95f); // 약간 따뜻한 색 + + // 보조 조명 (필/림 라이트) + var fillLightGo = new GameObject("[ThumbnailFillLight_Temp]"); + var fillLight = fillLightGo.AddComponent(); + fillLight.type = LightType.Directional; + fillLight.transform.rotation = Quaternion.Euler(30, 150, 0); + fillLight.intensity = 0.5f; + fillLight.color = new Color(0.8f, 0.85f, 1f); // 약간 차가운 색 + fillLightGo.transform.SetParent(backupLightGo.transform); + } + + // 렌더링 + camera.Render(); + + // 텍스처 읽기 + var texture = new Texture2D(THUMBNAIL_CAPTURE_SIZE, THUMBNAIL_CAPTURE_SIZE, TextureFormat.RGBA32, false); + RenderTexture.active = rt; + texture.ReadPixels(new Rect(0, 0, THUMBNAIL_CAPTURE_SIZE, THUMBNAIL_CAPTURE_SIZE), 0, 0); + texture.Apply(); + RenderTexture.active = null; + + // 정리 + if (backupLightGo != null) DestroyImmediate(backupLightGo); + DestroyImmediate(cameraGo); + DestroyImmediate(rt); + DestroyImmediate(tempInstance); + + return texture; + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"[PropBrowser] GeneratePreviewTexture 오류: {ex.Message}"); + DestroyImmediate(tempInstance); + return null; + } + } + + /// + /// 오브젝트의 전체 바운드 계산 (모든 렌더러, 스킨드 메시, 파티클 포함) + /// + private Bounds CalculateTotalBounds(GameObject obj) + { + var renderers = obj.GetComponentsInChildren(true); + var skinnedMeshRenderers = obj.GetComponentsInChildren(true); + var meshFilters = obj.GetComponentsInChildren(true); + + Bounds bounds = new Bounds(obj.transform.position, Vector3.zero); + bool hasBounds = false; + + // 렌더러 바운드 수집 + foreach (var renderer in renderers) + { + // 파티클 시스템은 제외 (불필요하게 큰 바운드 생성) + if (renderer is ParticleSystemRenderer) continue; + + if (!hasBounds) + { + bounds = renderer.bounds; + hasBounds = true; + } + else + { + bounds.Encapsulate(renderer.bounds); + } + } + + // 바운드가 없으면 MeshFilter에서 직접 계산 + if (!hasBounds) + { + foreach (var mf in meshFilters) + { + if (mf.sharedMesh == null) continue; + + var meshBounds = mf.sharedMesh.bounds; + var worldBounds = new Bounds( + mf.transform.TransformPoint(meshBounds.center), + Vector3.Scale(meshBounds.size, mf.transform.lossyScale) + ); + + if (!hasBounds) + { + bounds = worldBounds; + hasBounds = true; + } + else + { + bounds.Encapsulate(worldBounds); + } + } + } + + return bounds; + } + + /// + /// 카메라를 바운더리에 맞게 위치시켜 전체가 보이도록 함 + /// + private void PositionCameraToFitBounds(Camera camera, Bounds bounds) + { + // 대각선 방향에서 촬영 (앞쪽에서 45도 각도) + // Z+ 방향이 앞쪽이므로 Z를 양수로 설정하여 앞에서 촬영 + Vector3 viewDirection = new Vector3(-0.7f, 0.5f, 1f).normalized; + + // 바운드 크기 계산 + float boundsSphereRadius = bounds.extents.magnitude; + + // FOV 기반 거리 계산 - 전체가 화면에 들어오도록 + float fov = camera.fieldOfView * Mathf.Deg2Rad; + float distance = boundsSphereRadius / Mathf.Sin(fov / 2f); + + // 약간의 여백 추가 (1.2배) + distance *= 1.2f; + + // 카메라 위치 설정 + camera.transform.position = bounds.center + viewDirection * distance; + camera.transform.LookAt(bounds.center); + + // 클리핑 평면 조정 + camera.nearClipPlane = Mathf.Max(0.01f, distance - boundsSphereRadius * 2f); + camera.farClipPlane = distance + boundsSphereRadius * 2f; + } + + #endregion + + #region Web Upload + + private void ShowWebUploadMenu() + { + var menu = new GenericMenu(); + + menu.AddItem(new GUIContent("웹사이트에 업로드"), false, () => UploadToWebsite()); + menu.AddItem(new GUIContent("업로드 설정 열기"), false, () => WebsitePropExporter.ShowWindow()); + + menu.ShowAsContext(); + } + + private void UploadToWebsite() + { + if (_database == null || _database.props.Count == 0) + { + EditorUtility.DisplayDialog("오류", "업로드할 프랍이 없습니다.", "확인"); + return; + } + + // WebsitePropExporter 윈도우 열고 바로 업로드 + var exporter = EditorWindow.GetWindow("프랍 업로드"); + exporter.UploadToWebsite(); + } + + #endregion + } +} diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropBrowserWindow.cs.meta b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropBrowserWindow.cs.meta new file mode 100644 index 00000000..10b5999f --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropBrowserWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 57624d2ef1d480c48bd4af170df78730 \ No newline at end of file diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropSyncSettings.cs b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropSyncSettings.cs new file mode 100644 index 00000000..a5496829 --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropSyncSettings.cs @@ -0,0 +1,62 @@ +using System; +using UnityEngine; + +namespace Streamingle.Prop.Editor +{ + /// + /// 프랍 동기화 설정을 저장하는 ScriptableObject + /// + [CreateAssetMenu(fileName = "PropSyncSettings", menuName = "Streamingle/Prop Sync Settings")] + public class PropSyncSettings : ScriptableObject + { + [Header("웹사이트 API 설정")] + [Tooltip("프랍 API 엔드포인트 URL (예: https://minglestudio.co.kr/api/props)")] + public string apiEndpoint = "https://minglestudio.co.kr/api/props"; + + [Tooltip("API 인증 키 (선택사항)")] + public string apiKey = ""; + + [Header("Git 설정 (썸네일 URL용)")] + [Tooltip("Git 서버 URL (예: https://kindnick-git.duckdns.org)")] + public string gitServerUrl = "https://kindnick-git.duckdns.org"; + + [Tooltip("Git 리포지토리 경로 (예: kindnick/Streamingle_URP)")] + public string gitRepoPath = "kindnick/Streamingle_URP"; + + [Tooltip("Git 브랜치 (예: main)")] + public string gitBranch = "main"; + + [Header("웹사이트 설정")] + [Tooltip("프랍 페이지 URL (브라우저에서 열기용)")] + public string websiteUrl = "https://minglestudio.co.kr/props"; + + /// + /// Git Media 파일 URL 생성 (Gitea 형식) + /// + public string GetGitRawUrl(string assetPath) + { + // Assets/ResourcesData/Prop/... 형식의 경로를 Git Media URL로 변환 + string relativePath = assetPath.Replace("\\", "/"); + + // 경로의 각 세그먼트를 URL 인코딩 (슬래시는 유지) + string[] segments = relativePath.Split('/'); + for (int i = 0; i < segments.Length; i++) + { + segments[i] = Uri.EscapeDataString(segments[i]); + } + string encodedPath = string.Join("/", segments); + + return $"{gitServerUrl}/{gitRepoPath}/media/branch/{gitBranch}/{encodedPath}"; + } + + /// + /// API 설정이 유효한지 확인 + /// + public bool IsValid() + { + return !string.IsNullOrEmpty(apiEndpoint) && + !string.IsNullOrEmpty(gitServerUrl) && + !string.IsNullOrEmpty(gitRepoPath); + } + } +} diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropSyncSettings.cs.meta b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropSyncSettings.cs.meta new file mode 100644 index 00000000..91c6507a --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/PropSyncSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 98eaf226422c2a746ab5e281d74c76e8 \ No newline at end of file diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/WebsitePropExporter.cs b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/WebsitePropExporter.cs new file mode 100644 index 00000000..51a878c2 --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/WebsitePropExporter.cs @@ -0,0 +1,409 @@ +using System; +using System.Collections.Generic; +using System.Text; +using UnityEditor; +using UnityEngine; +using UnityEngine.Networking; +using Newtonsoft.Json; + +namespace Streamingle.Prop.Editor +{ + /// + /// 프랍 데이터를 웹사이트 API로 업로드 + /// 썸네일은 Git URL을 사용 + /// + public class WebsitePropExporter : EditorWindow + { + private PropSyncSettings _settings; + private PropDatabase _database; + private string _statusMessage = ""; + private MessageType _statusType = MessageType.Info; + private bool _isExporting; + private UnityWebRequestAsyncOperation _currentRequest; + + private const string SETTINGS_PATH = "Assets/Resources/Settings/PropSyncSettings.asset"; + private const string DATABASE_PATH = "Assets/Resources/Settings/PropDatabase.asset"; + + [MenuItem("Streamingle/Upload Props to Website")] + public static void ShowWindow() + { + var window = GetWindow("프랍 업로드"); + window.minSize = new Vector2(450, 400); + window.Show(); + } + + private void OnEnable() + { + LoadSettings(); + } + + private void LoadSettings() + { + _settings = AssetDatabase.LoadAssetAtPath(SETTINGS_PATH); + _database = AssetDatabase.LoadAssetAtPath(DATABASE_PATH); + } + + private void OnGUI() + { + EditorGUILayout.Space(10); + EditorGUILayout.LabelField("프랍 웹사이트 업로드", EditorStyles.boldLabel); + EditorGUILayout.Space(10); + + // 설정 확인 + if (_settings == null) + { + EditorGUILayout.HelpBox("PropSyncSettings을 찾을 수 없습니다.\nAssets/Resources/Settings/PropSyncSettings.asset 을 생성해주세요.", MessageType.Warning); + + if (GUILayout.Button("설정 파일 생성")) + { + CreateSettingsAsset(); + } + return; + } + + if (_database == null) + { + EditorGUILayout.HelpBox("PropDatabase를 찾을 수 없습니다.\n프랍 브라우저에서 새로고침을 눌러주세요.", MessageType.Warning); + return; + } + + // API 설정 + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.LabelField("API 설정", EditorStyles.boldLabel); + + EditorGUI.BeginChangeCheck(); + + _settings.apiEndpoint = EditorGUILayout.TextField("API 엔드포인트", _settings.apiEndpoint); + _settings.apiKey = EditorGUILayout.TextField("API 키 (선택)", _settings.apiKey); + + EditorGUILayout.Space(5); + EditorGUILayout.LabelField("Git 설정 (썸네일 URL용)", EditorStyles.boldLabel); + _settings.gitServerUrl = EditorGUILayout.TextField("Git Server URL", _settings.gitServerUrl); + _settings.gitRepoPath = EditorGUILayout.TextField("Repo Path", _settings.gitRepoPath); + _settings.gitBranch = EditorGUILayout.TextField("Branch", _settings.gitBranch); + + EditorGUILayout.Space(5); + _settings.websiteUrl = EditorGUILayout.TextField("웹사이트 URL", _settings.websiteUrl); + + if (EditorGUI.EndChangeCheck()) + { + EditorUtility.SetDirty(_settings); + AssetDatabase.SaveAssets(); + } + + EditorGUILayout.EndVertical(); + + // 설정 유효성 검사 + EditorGUILayout.Space(10); + + if (!_settings.IsValid()) + { + EditorGUILayout.HelpBox("API 엔드포인트와 Git 설정을 입력해주세요.", MessageType.Warning); + } + + // 썸네일 URL 예시 + EditorGUILayout.Space(5); + EditorGUILayout.LabelField("썸네일 URL 예시:", EditorStyles.boldLabel); + string exampleUrl = _settings.GetGitRawUrl("Assets/ResourcesData/Prop/예시/Thumbnail/예시_thumbnail.png"); + EditorGUILayout.SelectableLabel(exampleUrl, EditorStyles.miniLabel, GUILayout.Height(20)); + + // 데이터베이스 정보 + EditorGUILayout.Space(10); + EditorGUILayout.LabelField($"프랍 수: {_database.props.Count}개", EditorStyles.miniLabel); + + // 업로드 버튼 + EditorGUILayout.Space(20); + + GUI.enabled = _settings.IsValid() && !_isExporting; + + if (GUILayout.Button("웹사이트에 업로드", GUILayout.Height(35))) + { + UploadToWebsite(); + } + + EditorGUILayout.Space(5); + + if (GUILayout.Button("업로드 후 브라우저에서 열기", GUILayout.Height(30))) + { + UploadToWebsite(() => + { + if (!string.IsNullOrEmpty(_settings.websiteUrl)) + { + Application.OpenURL(_settings.websiteUrl); + } + }); + } + + EditorGUILayout.Space(5); + + if (GUILayout.Button("API 연결 테스트", GUILayout.Height(25))) + { + TestApiConnection(); + } + + GUI.enabled = true; + + // 상태 메시지 + if (!string.IsNullOrEmpty(_statusMessage)) + { + EditorGUILayout.Space(10); + EditorGUILayout.HelpBox(_statusMessage, _statusType); + } + } + + private void CreateSettingsAsset() + { + // Settings 폴더 확인 + if (!AssetDatabase.IsValidFolder("Assets/Resources")) + { + AssetDatabase.CreateFolder("Assets", "Resources"); + } + if (!AssetDatabase.IsValidFolder("Assets/Resources/Settings")) + { + AssetDatabase.CreateFolder("Assets/Resources", "Settings"); + } + + // 새 설정 파일 생성 + var settings = CreateInstance(); + AssetDatabase.CreateAsset(settings, SETTINGS_PATH); + AssetDatabase.SaveAssets(); + + _settings = settings; + UnityEngine.Debug.Log("[WebsitePropExporter] PropSyncSettings 생성됨"); + } + + /// + /// 웹사이트에 프랍 데이터 업로드 (개별 프리펩 단위) + /// + public void UploadToWebsite(Action onSuccess = null) + { + _isExporting = true; + _statusMessage = "업로드 준비 중..."; + _statusType = MessageType.Info; + + try + { + // JSON 데이터 생성 - 개별 프리펩 단위로 내보내기 + var exportData = new WebsitePropData + { + lastUpdated = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"), + props = new List() + }; + + foreach (var propInfo in _database.props) + { + // 폴더에 프리펩이 있으면 각 프리펩을 개별 항목으로 추가 + if (propInfo.prefabPaths.Count > 0) + { + foreach (var prefabPath in propInfo.prefabPaths) + { + string prefabName = System.IO.Path.GetFileNameWithoutExtension(prefabPath); + + // 개별 프리펩 썸네일 경로 찾기 + string prefabThumbnailPath = $"{propInfo.folderPath}/Thumbnail/{prefabName}_thumbnail.png"; + string prefabThumbnailFullPath = prefabThumbnailPath.Replace("Assets/", Application.dataPath + "/"); + + // 개별 썸네일이 있으면 사용, 없으면 폴더 썸네일 사용 + string thumbnailPath = System.IO.File.Exists(prefabThumbnailFullPath) + ? prefabThumbnailPath + : propInfo.thumbnailPath; + + var item = new WebsitePropItem + { + name = prefabName, // 프리펩 파일 이름 사용 + folderPath = propInfo.folderPath, + folderName = propInfo.propName, // 원래 폴더 이름도 유지 + prefabPath = prefabPath, + prefabCount = 1, + modelCount = propInfo.modelPaths.Count, + textureCount = propInfo.textureCount, + materialCount = propInfo.materialCount, + thumbnailUrl = !string.IsNullOrEmpty(thumbnailPath) + ? _settings.GetGitRawUrl(thumbnailPath) + : null + }; + + exportData.props.Add(item); + } + } + else + { + // 프리펩이 없는 경우 폴더명으로 추가 + var item = new WebsitePropItem + { + name = propInfo.propName, + folderPath = propInfo.folderPath, + folderName = propInfo.propName, + prefabPath = null, + prefabCount = 0, + modelCount = propInfo.modelPaths.Count, + textureCount = propInfo.textureCount, + materialCount = propInfo.materialCount, + thumbnailUrl = !string.IsNullOrEmpty(propInfo.thumbnailPath) + ? _settings.GetGitRawUrl(propInfo.thumbnailPath) + : null + }; + + exportData.props.Add(item); + } + } + + // JSON 문자열 생성 + string json = JsonConvert.SerializeObject(exportData, Formatting.Indented); + + // HTTP POST 요청 + SendPostRequest(json, onSuccess); + } + catch (Exception ex) + { + _statusMessage = $"데이터 준비 실패: {ex.Message}"; + _statusType = MessageType.Error; + _isExporting = false; + UnityEngine.Debug.LogError($"[WebsitePropExporter] 데이터 준비 실패: {ex.Message}"); + } + } + + private void SendPostRequest(string jsonData, Action onSuccess) + { + _statusMessage = "서버에 업로드 중..."; + + var request = new UnityWebRequest(_settings.apiEndpoint, "POST"); + byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonData); + request.uploadHandler = new UploadHandlerRaw(bodyRaw); + request.downloadHandler = new DownloadHandlerBuffer(); + request.SetRequestHeader("Content-Type", "application/json"); + + if (!string.IsNullOrEmpty(_settings.apiKey)) + { + request.SetRequestHeader("X-API-Key", _settings.apiKey); + } + + _currentRequest = request.SendWebRequest(); + _currentRequest.completed += operation => + { + HandleUploadResponse(request, onSuccess); + }; + + // 진행 상황 업데이트를 위한 에디터 갱신 + EditorApplication.update += UpdateProgress; + } + + private void UpdateProgress() + { + if (_currentRequest != null && !_currentRequest.isDone) + { + _statusMessage = $"업로드 중... {(_currentRequest.progress * 100):F0}%"; + Repaint(); + } + else + { + EditorApplication.update -= UpdateProgress; + } + } + + private void HandleUploadResponse(UnityWebRequest request, Action onSuccess) + { + _isExporting = false; + + if (request.result == UnityWebRequest.Result.Success) + { + try + { + var response = JsonConvert.DeserializeObject(request.downloadHandler.text); + if (response.success) + { + _statusMessage = $"업로드 완료!\n{response.message}\n업데이트: {response.lastUpdated}"; + _statusType = MessageType.Info; + UnityEngine.Debug.Log($"[WebsitePropExporter] 업로드 성공: {response.message}"); + onSuccess?.Invoke(); + } + else + { + _statusMessage = $"서버 오류: {response.error}"; + _statusType = MessageType.Error; + } + } + catch (Exception ex) + { + _statusMessage = $"응답 파싱 실패: {ex.Message}\n응답: {request.downloadHandler.text}"; + _statusType = MessageType.Error; + } + } + else + { + _statusMessage = $"업로드 실패!\n{request.error}\n상태코드: {request.responseCode}"; + _statusType = MessageType.Error; + UnityEngine.Debug.LogError($"[WebsitePropExporter] 업로드 실패: {request.error}"); + } + + request.Dispose(); + Repaint(); + } + + private void TestApiConnection() + { + _statusMessage = "API 연결 테스트 중..."; + _statusType = MessageType.Info; + + var request = UnityWebRequest.Get(_settings.apiEndpoint); + var operation = request.SendWebRequest(); + + operation.completed += op => + { + if (request.result == UnityWebRequest.Result.Success) + { + _statusMessage = $"API 연결 성공!\n응답: {request.downloadHandler.text.Substring(0, Math.Min(200, request.downloadHandler.text.Length))}..."; + _statusType = MessageType.Info; + } + else + { + _statusMessage = $"API 연결 실패!\n{request.error}\n상태코드: {request.responseCode}"; + _statusType = MessageType.Error; + } + + request.Dispose(); + Repaint(); + }; + } + } + + /// + /// API 응답 구조 + /// + [Serializable] + public class PropApiResponse + { + public bool success; + public string message; + public string error; + public string lastUpdated; + public int count; + } + + /// + /// 웹사이트용 프랍 데이터 구조 + /// + [Serializable] + public class WebsitePropData + { + public string lastUpdated; + public List props; + } + + /// + /// 웹사이트용 개별 프랍 항목 (프리펩 단위) + /// + [Serializable] + public class WebsitePropItem + { + public string name; // 프리펩 이름 + public string folderPath; // 폴더 경로 + public string folderName; // 원래 폴더 이름 + public string prefabPath; // 프리펩 경로 + public int prefabCount; + public int modelCount; + public int textureCount; + public int materialCount; + public string thumbnailUrl; + } +} diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/WebsitePropExporter.cs.meta b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/WebsitePropExporter.cs.meta new file mode 100644 index 00000000..3e4fdaa9 --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Prop/Editor/WebsitePropExporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b7f64cb7ac7294a41a80919b89f3d527 \ No newline at end of file diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Prop/PropData.cs b/Assets/Scripts/Streamingle/StreamingleControl/Prop/PropData.cs new file mode 100644 index 00000000..5bdc420c --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Prop/PropData.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Streamingle.Prop +{ + /// + /// 프랍 정보를 담는 데이터 클래스 + /// + [Serializable] + public class PropInfo + { + public string propName; // 프랍 이름 + public string folderPath; // 프랍 폴더 경로 (Assets/...) + public string thumbnailPath; // 썸네일 이미지 경로 + public Texture2D thumbnail; // 로드된 썸네일 (런타임용) + public List prefabPaths = new List(); // 프리펩 경로들 + public List modelPaths = new List(); // 모델 파일 경로들 + public int textureCount; // 텍스처 파일 수 + public int materialCount; // 머티리얼 파일 수 + + public string DisplayName => propName; + + /// + /// 대표 프리펩 경로 (첫 번째) + /// + public string MainPrefabPath => prefabPaths.Count > 0 ? prefabPaths[0] : null; + } + + /// + /// 프랍 데이터를 저장하는 ScriptableObject + /// + [CreateAssetMenu(fileName = "PropDatabase", menuName = "Streamingle/Prop Database")] + public class PropDatabase : ScriptableObject + { + public List props = new List(); + + /// + /// 프랍 이름으로 검색 + /// + public PropInfo FindByName(string propName) + { + return props.Find(p => p.propName == propName); + } + + /// + /// 폴더 경로로 검색 + /// + public PropInfo FindByPath(string folderPath) + { + return props.Find(p => p.folderPath == folderPath); + } + } +} diff --git a/Assets/Scripts/Streamingle/StreamingleControl/Prop/PropData.cs.meta b/Assets/Scripts/Streamingle/StreamingleControl/Prop/PropData.cs.meta new file mode 100644 index 00000000..304a7211 --- /dev/null +++ b/Assets/Scripts/Streamingle/StreamingleControl/Prop/PropData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e148686fc073a10478d6beb39b4cd8d8 \ No newline at end of file