Feat: 프랍 브라우저 및 웹 업로드 시스템 추가

- PropBrowserWindow: Unity 에디터용 프랍 브라우저
  - 개별 프리펩 단위로 표시 (폴더별 묶음 X)
  - 씬 조명 기반 썸네일 생성
  - 앞쪽에서 촬영하도록 카메라 각도 수정
- WebsitePropExporter: 웹 API 업로드 기능
  - 개별 프리펩별 썸네일 URL 지원
- PropSyncSettings: API 및 Git URL 설정
- PropData: 프랍 데이터 구조체

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
user 2026-01-08 21:24:57 +09:00
parent f2cd9878cb
commit 35f50ba25b
10 changed files with 1943 additions and 0 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 57624d2ef1d480c48bd4af170df78730

View File

@ -0,0 +1,62 @@
using System;
using UnityEngine;
namespace Streamingle.Prop.Editor
{
/// <summary>
/// 프랍 동기화 설정을 저장하는 ScriptableObject
/// </summary>
[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";
/// <summary>
/// Git Media 파일 URL 생성 (Gitea 형식)
/// </summary>
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}";
}
/// <summary>
/// API 설정이 유효한지 확인
/// </summary>
public bool IsValid()
{
return !string.IsNullOrEmpty(apiEndpoint) &&
!string.IsNullOrEmpty(gitServerUrl) &&
!string.IsNullOrEmpty(gitRepoPath);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 98eaf226422c2a746ab5e281d74c76e8

View File

@ -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
{
/// <summary>
/// 프랍 데이터를 웹사이트 API로 업로드
/// 썸네일은 Git URL을 사용
/// </summary>
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<WebsitePropExporter>("프랍 업로드");
window.minSize = new Vector2(450, 400);
window.Show();
}
private void OnEnable()
{
LoadSettings();
}
private void LoadSettings()
{
_settings = AssetDatabase.LoadAssetAtPath<PropSyncSettings>(SETTINGS_PATH);
_database = AssetDatabase.LoadAssetAtPath<PropDatabase>(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<PropSyncSettings>();
AssetDatabase.CreateAsset(settings, SETTINGS_PATH);
AssetDatabase.SaveAssets();
_settings = settings;
UnityEngine.Debug.Log("[WebsitePropExporter] PropSyncSettings 생성됨");
}
/// <summary>
/// 웹사이트에 프랍 데이터 업로드 (개별 프리펩 단위)
/// </summary>
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<WebsitePropItem>()
};
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<PropApiResponse>(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();
};
}
}
/// <summary>
/// API 응답 구조
/// </summary>
[Serializable]
public class PropApiResponse
{
public bool success;
public string message;
public string error;
public string lastUpdated;
public int count;
}
/// <summary>
/// 웹사이트용 프랍 데이터 구조
/// </summary>
[Serializable]
public class WebsitePropData
{
public string lastUpdated;
public List<WebsitePropItem> props;
}
/// <summary>
/// 웹사이트용 개별 프랍 항목 (프리펩 단위)
/// </summary>
[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;
}
}

View File

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

View File

@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Streamingle.Prop
{
/// <summary>
/// 프랍 정보를 담는 데이터 클래스
/// </summary>
[Serializable]
public class PropInfo
{
public string propName; // 프랍 이름
public string folderPath; // 프랍 폴더 경로 (Assets/...)
public string thumbnailPath; // 썸네일 이미지 경로
public Texture2D thumbnail; // 로드된 썸네일 (런타임용)
public List<string> prefabPaths = new List<string>(); // 프리펩 경로들
public List<string> modelPaths = new List<string>(); // 모델 파일 경로들
public int textureCount; // 텍스처 파일 수
public int materialCount; // 머티리얼 파일 수
public string DisplayName => propName;
/// <summary>
/// 대표 프리펩 경로 (첫 번째)
/// </summary>
public string MainPrefabPath => prefabPaths.Count > 0 ? prefabPaths[0] : null;
}
/// <summary>
/// 프랍 데이터를 저장하는 ScriptableObject
/// </summary>
[CreateAssetMenu(fileName = "PropDatabase", menuName = "Streamingle/Prop Database")]
public class PropDatabase : ScriptableObject
{
public List<PropInfo> props = new List<PropInfo>();
/// <summary>
/// 프랍 이름으로 검색
/// </summary>
public PropInfo FindByName(string propName)
{
return props.Find(p => p.propName == propName);
}
/// <summary>
/// 폴더 경로로 검색
/// </summary>
public PropInfo FindByPath(string folderPath)
{
return props.Find(p => p.folderPath == folderPath);
}
}
}

View File

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