데이터 파이프라인 구축
This commit is contained in:
16
.gitignore
vendored
16
.gitignore
vendored
@@ -473,3 +473,19 @@ FodyWeavers.xsd
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
|
||||
# .gitignore 파일 생성
|
||||
@"
|
||||
# Excel 임시 파일
|
||||
~$*.xlsx
|
||||
*.tmp
|
||||
|
||||
# 백업 폴더 (로컬만)
|
||||
GameData/Backups/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
DataTools/*.log
|
||||
|
||||
# Unity (기존 내용 유지)
|
||||
"@ | Out-File -FilePath ".gitignore" -Encoding UTF8
|
||||
29
Assets/Editor/DataImporter/CLIImporter.cs
Normal file
29
Assets/Editor/DataImporter/CLIImporter.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
// Assets/Editor/DataImporter/CLIImporter.cs
|
||||
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
|
||||
namespace DigAndDefend.Editor
|
||||
{
|
||||
public static class CLIImporter
|
||||
{
|
||||
public static void ImportAllDataCLI()
|
||||
{
|
||||
Debug.Log("=== CLI Import 시작 ===");
|
||||
|
||||
try
|
||||
{
|
||||
CSVToSOImporter.ImportAll();
|
||||
|
||||
Debug.Log("=== CLI Import 성공 ===");
|
||||
EditorApplication.Exit(0);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogError($"=== CLI Import 실패 ===");
|
||||
Debug.LogException(e);
|
||||
EditorApplication.Exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Editor/DataImporter/CLIImporter.cs.meta
Normal file
2
Assets/Editor/DataImporter/CLIImporter.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 377287127c092124ca29e4d10c6828b8
|
||||
94
Assets/Editor/DataImporter/CSVDebugger.cs
Normal file
94
Assets/Editor/DataImporter/CSVDebugger.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
// Assets/Editor/DataImporter/CSVDebugger.cs
|
||||
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DigAndDefend.Editor
|
||||
{
|
||||
public static class CSVDebugger
|
||||
{
|
||||
[MenuItem("DigAndDefend/Debug CSV Files")]
|
||||
public static void DebugCSVFiles()
|
||||
{
|
||||
string gameDataPath = Path.Combine(Application.dataPath, "..", "GameData");
|
||||
var csvFiles = Directory.GetFiles(gameDataPath, "*.csv");
|
||||
|
||||
Debug.Log("=== CSV 파일 디버그 ===\n");
|
||||
|
||||
foreach (var csvPath in csvFiles)
|
||||
{
|
||||
string fileName = Path.GetFileName(csvPath);
|
||||
|
||||
if (fileName.StartsWith("."))
|
||||
continue;
|
||||
|
||||
Debug.Log($"📄 파일: {fileName}");
|
||||
|
||||
// 파일 크기
|
||||
FileInfo fileInfo = new FileInfo(csvPath);
|
||||
Debug.Log($" 📊 크기: {fileInfo.Length} bytes");
|
||||
|
||||
// 인코딩 테스트
|
||||
try
|
||||
{
|
||||
// UTF-8로 읽기
|
||||
var lines = File.ReadAllLines(csvPath, Encoding.UTF8);
|
||||
Debug.Log($" ✅ UTF-8 읽기 성공: {lines.Length}줄");
|
||||
|
||||
// ⭐ 모든 줄 출력
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
string line = lines[i];
|
||||
Debug.Log($" 📋 [{i}] 길이:{line.Length} | 내용: '{line}'");
|
||||
|
||||
// 특수문자 확인
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
Debug.Log($" ⚠️ 빈 줄 또는 공백만 있음");
|
||||
}
|
||||
|
||||
// 바이트 출력 (첫 20바이트만)
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(line);
|
||||
string byteStr = "";
|
||||
for (int j = 0; j < Mathf.Min(bytes.Length, 20); j++)
|
||||
{
|
||||
byteStr += $"{bytes[j]:X2} ";
|
||||
}
|
||||
if (bytes.Length > 20) byteStr += "...";
|
||||
Debug.Log($" 바이트: {byteStr}");
|
||||
}
|
||||
|
||||
// BOM 체크
|
||||
byte[] fileBytes = File.ReadAllBytes(csvPath);
|
||||
if (fileBytes.Length >= 3 && fileBytes[0] == 0xEF && fileBytes[1] == 0xBB && fileBytes[2] == 0xBF)
|
||||
{
|
||||
Debug.Log($" ℹ️ UTF-8 BOM 있음");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($" ℹ️ BOM 없음");
|
||||
}
|
||||
|
||||
// 전체 파일 바이트 (처음 100바이트만)
|
||||
string fileBytesStr = "";
|
||||
for (int i = 0; i < Mathf.Min(fileBytes.Length, 100); i++)
|
||||
{
|
||||
fileBytesStr += $"{fileBytes[i]:X2} ";
|
||||
if ((i + 1) % 20 == 0) fileBytesStr += "\n ";
|
||||
}
|
||||
Debug.Log($" 📦 파일 바이트 (처음 100):\n {fileBytesStr}");
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogError($" ❌ 읽기 실패: {e.Message}");
|
||||
}
|
||||
|
||||
Debug.Log("");
|
||||
}
|
||||
|
||||
Debug.Log("=== 디버그 완료 ===");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Editor/DataImporter/CSVDebugger.cs.meta
Normal file
2
Assets/Editor/DataImporter/CSVDebugger.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dbdd462d48b81304eb7cad44e0f554cb
|
||||
348
Assets/Editor/DataImporter/CSVToSOImporter.cs
Normal file
348
Assets/Editor/DataImporter/CSVToSOImporter.cs
Normal file
@@ -0,0 +1,348 @@
|
||||
// Assets/Editor/DataImporter/CSVToSOImporter.cs
|
||||
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DigAndDefend.Editor
|
||||
{
|
||||
public static class CSVToSOImporter
|
||||
{
|
||||
private static readonly string GAMEDATA_PATH = Path.Combine(
|
||||
Application.dataPath,
|
||||
"..", "GameData"
|
||||
);
|
||||
|
||||
private static readonly string SO_BASE_PATH = "Assets/Data/ScriptableObjects";
|
||||
|
||||
|
||||
public static void ImportAll()
|
||||
{
|
||||
Debug.Log("=== 전체 데이터 Import 시작 ===");
|
||||
|
||||
var csvFiles = Directory.GetFiles(GAMEDATA_PATH, "*.csv");
|
||||
|
||||
int totalSuccess = 0;
|
||||
int totalFail = 0;
|
||||
|
||||
foreach (var csvPath in csvFiles)
|
||||
{
|
||||
if (csvPath.Contains("Backups"))
|
||||
continue;
|
||||
|
||||
string schemaName = Path.GetFileNameWithoutExtension(csvPath);
|
||||
|
||||
if (schemaName.StartsWith("."))
|
||||
continue;
|
||||
|
||||
Debug.Log($"\n📋 {schemaName} Import 시작...");
|
||||
|
||||
if (ImportSchema(schemaName))
|
||||
{
|
||||
totalSuccess++;
|
||||
}
|
||||
else
|
||||
{
|
||||
totalFail++;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log("\n" + new string('=', 60));
|
||||
Debug.Log($"🎉 전체 Import 완료: 성공 {totalSuccess}개, 실패 {totalFail}개");
|
||||
Debug.Log(new string('=', 60));
|
||||
}
|
||||
|
||||
|
||||
public static bool ImportSchema(string schemaName)
|
||||
{
|
||||
string csvPath = Path.Combine(GAMEDATA_PATH, $"{schemaName}.csv");
|
||||
string outputPath = Path.Combine(SO_BASE_PATH, schemaName);
|
||||
|
||||
if (!File.Exists(csvPath))
|
||||
{
|
||||
Debug.LogError($"❌ CSV 파일을 찾을 수 없습니다: {csvPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(outputPath))
|
||||
{
|
||||
Directory.CreateDirectory(outputPath);
|
||||
}
|
||||
|
||||
string className = schemaName + "Data";
|
||||
Type dataType = FindScriptableObjectType(className);
|
||||
|
||||
if (dataType == null)
|
||||
{
|
||||
Debug.LogError($"❌ ScriptableObject 클래스를 찾을 수 없습니다: {className}");
|
||||
Debug.LogError($"💡 generate_csharp_classes.py를 먼저 실행하세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Debug.Log($" ✅ 클래스 발견: {dataType.FullName}");
|
||||
|
||||
int importCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var lines = File.ReadAllLines(csvPath, System.Text.Encoding.UTF8);
|
||||
|
||||
if (lines.Length < 2)
|
||||
{
|
||||
Debug.LogError("❌ CSV 파일에 데이터가 없습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var headers = ParseCSVLine(lines[0]);
|
||||
|
||||
Debug.Log($" 📊 {lines.Length - 1}개 행, {headers.Length}개 컬럼");
|
||||
|
||||
for (int lineIndex = 1; lineIndex < lines.Length; lineIndex++)
|
||||
{
|
||||
string line = lines[lineIndex].Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(line))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var values = ParseCSVLine(line);
|
||||
|
||||
if (values.Length != headers.Length)
|
||||
{
|
||||
Debug.LogWarning($" ⚠️ 행 {lineIndex + 1}: 컬럼 개수 불일치");
|
||||
continue;
|
||||
}
|
||||
|
||||
var so = ScriptableObject.CreateInstance(dataType);
|
||||
|
||||
for (int col = 0; col < headers.Length; col++)
|
||||
{
|
||||
string fieldName = headers[col];
|
||||
if (string.IsNullOrEmpty(fieldName))
|
||||
continue;
|
||||
|
||||
string camelFieldName = ToCamelCase(fieldName);
|
||||
Debug.Log(" 🔍 필드 매핑: " + camelFieldName);
|
||||
|
||||
FieldInfo field = dataType.GetField(camelFieldName, BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
if (field == null)
|
||||
{
|
||||
if (lineIndex == 1)
|
||||
{
|
||||
Debug.LogWarning($" ⚠️ 필드를 찾을 수 없습니다: {camelFieldName}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
string cellValue = values[col];
|
||||
SetFieldValue(so, field, cellValue);
|
||||
}
|
||||
|
||||
string assetName = GetAssetName(so, lineIndex);
|
||||
string fileName = assetName + ".asset";
|
||||
string assetPath = Path.Combine(outputPath, fileName);
|
||||
|
||||
AssetDatabase.CreateAsset(so, assetPath);
|
||||
importCount++;
|
||||
|
||||
Debug.Log($" ✅ {assetName}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($" ❌ 행 {lineIndex + 1} Import 실패: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
Debug.Log($" 🎉 {schemaName} Import 완료: {importCount}개");
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"❌ {schemaName} Import 실패: {e.Message}");
|
||||
Debug.LogException(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static string[] ParseCSVLine(string line)
|
||||
{
|
||||
var result = new List<string>();
|
||||
bool inQuotes = false;
|
||||
string currentValue = "";
|
||||
|
||||
for (int i = 0; i < line.Length; i++)
|
||||
{
|
||||
char c = line[i];
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
else if (c == ',' && !inQuotes)
|
||||
{
|
||||
result.Add(currentValue.Trim());
|
||||
currentValue = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
currentValue += c;
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(currentValue.Trim());
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
|
||||
private static Type FindScriptableObjectType(string className)
|
||||
{
|
||||
string fullName = $"DigAndDefend.Data.{className}";
|
||||
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
Type type = assembly.GetType(fullName);
|
||||
if (type != null && type.IsSubclassOf(typeof(ScriptableObject)))
|
||||
{
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private static string ToCamelCase(string snakeCase)
|
||||
{
|
||||
if (string.IsNullOrEmpty(snakeCase))
|
||||
return snakeCase;
|
||||
|
||||
var parts = snakeCase.Split('_');
|
||||
if (parts.Length == 1)
|
||||
return snakeCase;
|
||||
|
||||
string result = parts[0];
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
if (parts[i].Length > 0)
|
||||
{
|
||||
result += char.ToUpper(parts[i][0]) + parts[i].Substring(1);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
private static void SetFieldValue(object obj, FieldInfo field, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
field.SetValue(obj, null);
|
||||
return;
|
||||
}
|
||||
|
||||
Type fieldType = field.FieldType;
|
||||
|
||||
if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Nullable<>))
|
||||
{
|
||||
Type underlyingType = Nullable.GetUnderlyingType(fieldType);
|
||||
object convertedValue = ConvertValue(value, underlyingType);
|
||||
field.SetValue(obj, convertedValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
object convertedValue = ConvertValue(value, fieldType);
|
||||
field.SetValue(obj, convertedValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static object ConvertValue(string value, Type targetType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
if (targetType == typeof(int))
|
||||
{
|
||||
if (int.TryParse(value, out int result))
|
||||
return result;
|
||||
return 0;
|
||||
}
|
||||
else if (targetType == typeof(float))
|
||||
{
|
||||
if (float.TryParse(value, out float result))
|
||||
return result;
|
||||
return 0f;
|
||||
}
|
||||
else if (targetType == typeof(double))
|
||||
{
|
||||
if (double.TryParse(value, out double result))
|
||||
return result;
|
||||
return 0.0;
|
||||
}
|
||||
else if (targetType == typeof(bool))
|
||||
{
|
||||
string lower = value.ToLower();
|
||||
return lower == "true" || lower == "1" || lower == "yes";
|
||||
}
|
||||
else if (targetType == typeof(string))
|
||||
{
|
||||
// ⭐ 이스케이프된 줄바꿈 복원
|
||||
return value.Replace("\\n", "\n");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string GetAssetName(object so, int lineNumber)
|
||||
{
|
||||
Type type = so.GetType();
|
||||
|
||||
// ⭐ 1순위: id 필드 찾기
|
||||
FieldInfo idField = type.GetField("id", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (idField != null)
|
||||
{
|
||||
var idValue = idField.GetValue(so);
|
||||
if (idValue != null)
|
||||
{
|
||||
// 스키마 이름 추출 (TowersData → Towers, TowerData → Tower)
|
||||
string typeName = type.Name;
|
||||
if (typeName.EndsWith("Data"))
|
||||
{
|
||||
typeName = typeName.Substring(0, typeName.Length - 4);
|
||||
}
|
||||
|
||||
// Tower1, Enemy5 형식
|
||||
return $"{typeName}{idValue:D3}";
|
||||
}
|
||||
}
|
||||
|
||||
// ⭐ 2순위: name 필드 찾기
|
||||
FieldInfo nameField = type.GetField("name", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (nameField != null)
|
||||
{
|
||||
var nameValue = nameField.GetValue(so);
|
||||
if (nameValue != null && !string.IsNullOrWhiteSpace(nameValue.ToString()))
|
||||
{
|
||||
string name = nameValue.ToString();
|
||||
name = name.Replace(" ", "");
|
||||
name = System.Text.RegularExpressions.Regex.Replace(name, @"[^a-zA-Z0-9_가-힣]", "");
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// 3순위: 행 번호 사용
|
||||
return $"Data_Row{lineNumber}";
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Editor/DataImporter/CSVToSOImporter.cs.meta
Normal file
2
Assets/Editor/DataImporter/CSVToSOImporter.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9efcf294cdb0d8d4cae64f12e68690ef
|
||||
122
Assets/Editor/DataImporter/ImporterWindow.cs
Normal file
122
Assets/Editor/DataImporter/ImporterWindow.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
// Assets/Editor/DataImporter/ImporterWindow.cs
|
||||
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace DigAndDefend.Editor
|
||||
{
|
||||
public class ImporterWindow : EditorWindow
|
||||
{
|
||||
private Vector2 scrollPosition;
|
||||
|
||||
[MenuItem("DigAndDefend/Data Importer")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<ImporterWindow>("Data Importer");
|
||||
window.minSize = new Vector2(400, 300);
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
GUILayout.Label("CSV → ScriptableObject Importer", EditorStyles.boldLabel);
|
||||
GUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.HelpBox(
|
||||
"GameData 폴더의 CSV 파일을 ScriptableObject로 변환합니다.\n" +
|
||||
"자동 생성된 C# 클래스를 사용합니다.",
|
||||
MessageType.Info
|
||||
);
|
||||
|
||||
GUILayout.Space(10);
|
||||
|
||||
scrollPosition = GUILayout.BeginScrollView(scrollPosition);
|
||||
|
||||
string gameDataPath = Path.Combine(Application.dataPath, "..", "GameData");
|
||||
|
||||
if (!Directory.Exists(gameDataPath))
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"GameData 폴더를 찾을 수 없습니다.",
|
||||
MessageType.Warning
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
var csvFiles = Directory.GetFiles(gameDataPath, "*.csv")
|
||||
.Where(f => !f.Contains("Backups") && !Path.GetFileName(f).StartsWith("."))
|
||||
.ToArray();
|
||||
|
||||
if (csvFiles.Length == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"CSV 파일이 없습니다.\n" +
|
||||
"sync-from-notion.ps1을 먼저 실행하세요.",
|
||||
MessageType.Warning
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
GUILayout.Label("발견된 CSV 파일:", EditorStyles.boldLabel);
|
||||
GUILayout.Space(5);
|
||||
|
||||
foreach (var filePath in csvFiles)
|
||||
{
|
||||
string fileName = Path.GetFileNameWithoutExtension(filePath);
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
|
||||
GUILayout.Label($"📊 {fileName}", GUILayout.Width(200));
|
||||
|
||||
if (GUILayout.Button("Import", GUILayout.Width(100)))
|
||||
{
|
||||
ImportSingle(fileName);
|
||||
}
|
||||
|
||||
GUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GUILayout.EndScrollView();
|
||||
|
||||
GUILayout.Space(20);
|
||||
|
||||
GUI.backgroundColor = Color.green;
|
||||
if (GUILayout.Button("Import All Data", GUILayout.Height(50)))
|
||||
{
|
||||
if (EditorUtility.DisplayDialog(
|
||||
"전체 데이터 Import",
|
||||
"모든 CSV 파일을 읽어서 ScriptableObject를 생성합니다.\n" +
|
||||
"기존 파일은 덮어씌워집니다.",
|
||||
"Import All",
|
||||
"Cancel"))
|
||||
{
|
||||
CSVToSOImporter.ImportAll();
|
||||
}
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
|
||||
GUILayout.Space(10);
|
||||
|
||||
EditorGUILayout.HelpBox(
|
||||
"Import 후 Assets/Data/ScriptableObjects 폴더를 확인하세요.",
|
||||
MessageType.None
|
||||
);
|
||||
}
|
||||
|
||||
private void ImportSingle(string schemaName)
|
||||
{
|
||||
if (EditorUtility.DisplayDialog(
|
||||
$"{schemaName} Import",
|
||||
$"{schemaName}.csv를 읽어서 ScriptableObject를 생성합니다.\n" +
|
||||
"기존 파일은 덮어씌워집니다.",
|
||||
"Import",
|
||||
"Cancel"))
|
||||
{
|
||||
CSVToSOImporter.ImportSchema(schemaName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Editor/DataImporter/ImporterWindow.cs.meta
Normal file
2
Assets/Editor/DataImporter/ImporterWindow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6412ca5711dfaee4cb86b88ec75e0382
|
||||
475
DataTools/sync_from_notion.py
Normal file
475
DataTools/sync_from_notion.py
Normal file
@@ -0,0 +1,475 @@
|
||||
# DataTools/sync_from_notion.py
|
||||
"""노션 스키마 → CSV 동기화 (자동 발견)"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import pandas as pd
|
||||
from notion_client import Client
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# ===== 설정 =====
|
||||
NOTION_API_KEY = "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"
|
||||
notion = Client(auth=NOTION_API_KEY) if NOTION_API_KEY else None
|
||||
|
||||
# ⭐ 부모 페이지 ID만 설정 (1회)
|
||||
SCHEMA_PARENT_PAGE_ID = "2ef94d45b1a380438d66fabc0c86b3d7"
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
GAMEDATA_DIR = SCRIPT_DIR.parent / "GameData"
|
||||
BACKUP_DIR = GAMEDATA_DIR / "Backups"
|
||||
BACKUP_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# ===== 유틸리티 함수 =====
|
||||
|
||||
def clean_page_title(title):
|
||||
"""
|
||||
노션 페이지 제목 → 스키마 이름 변환
|
||||
|
||||
규칙:
|
||||
1. 이모지, 특수문자 제거
|
||||
2. "스키마", "Schema" 제거
|
||||
3. 공백 제거
|
||||
|
||||
예시:
|
||||
- "🏰 타워 스키마" → "타워"
|
||||
- "Tower Schema" → "Tower"
|
||||
- "Enemies" → "Enemies"
|
||||
"""
|
||||
# 1. 이모지 및 특수문자 제거
|
||||
cleaned = re.sub(r'[^\w\s가-힣]', '', title).strip()
|
||||
|
||||
# 2. "스키마", "Schema" 제거
|
||||
cleaned = re.sub(r'\s*(스키마|Schema)\s*', '', cleaned, flags=re.IGNORECASE).strip()
|
||||
|
||||
# 3. 공백 제거
|
||||
cleaned = cleaned.replace(' ', '')
|
||||
|
||||
# 4. 비어있으면 원본 반환
|
||||
if not cleaned:
|
||||
return title.replace(' ', '')
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def discover_schema_pages(parent_id=None, depth=0, max_depth=3):
|
||||
"""
|
||||
부모 페이지의 하위 페이지들을 재귀적으로 탐색하여 스키마 발견
|
||||
|
||||
Args:
|
||||
parent_id: 탐색할 부모 페이지 ID (None이면 SCHEMA_PARENT_PAGE_ID 사용)
|
||||
depth: 현재 깊이 (0부터 시작)
|
||||
max_depth: 최대 탐색 깊이 (기본 3단계)
|
||||
|
||||
반환:
|
||||
{
|
||||
"타워": "page_id_1",
|
||||
"적유닛": "page_id_2",
|
||||
...
|
||||
}
|
||||
"""
|
||||
if not notion:
|
||||
raise ValueError("Notion API 클라이언트가 초기화되지 않았습니다.")
|
||||
|
||||
if parent_id is None:
|
||||
if not SCHEMA_PARENT_PAGE_ID or SCHEMA_PARENT_PAGE_ID == "노션_데이터_스키마_정의_페이지_ID":
|
||||
raise ValueError(
|
||||
"SCHEMA_PARENT_PAGE_ID가 설정되지 않았습니다.\n"
|
||||
"sync_from_notion.py 파일에서 부모 페이지 ID를 설정하세요."
|
||||
)
|
||||
parent_id = SCHEMA_PARENT_PAGE_ID
|
||||
|
||||
# 최대 깊이 체크
|
||||
if depth > max_depth:
|
||||
return {}
|
||||
|
||||
indent = " " * depth
|
||||
|
||||
if depth == 0:
|
||||
print("🔍 스키마 페이지 자동 발견 중...")
|
||||
|
||||
try:
|
||||
# 부모 페이지의 자식 블록 가져오기
|
||||
children = notion.blocks.children.list(block_id=parent_id)
|
||||
|
||||
schemas = {}
|
||||
|
||||
for block in children['results']:
|
||||
if block['type'] == 'child_page':
|
||||
page_id = block['id']
|
||||
page_title = block['child_page']['title']
|
||||
|
||||
# 제목 정리
|
||||
schema_name = clean_page_title(page_title)
|
||||
|
||||
print(f"{indent}📋 발견: '{page_title}'", end="")
|
||||
|
||||
# 이 페이지에 테이블이 있는지 확인
|
||||
has_table = check_page_has_table(page_id)
|
||||
|
||||
if has_table:
|
||||
# 테이블이 있으면 스키마로 등록
|
||||
schemas[schema_name] = page_id
|
||||
print(f" → {schema_name} ✅")
|
||||
else:
|
||||
# 테이블이 없으면 하위 페이지 탐색
|
||||
print(f" (폴더)")
|
||||
child_schemas = discover_schema_pages(page_id, depth + 1, max_depth)
|
||||
schemas.update(child_schemas)
|
||||
|
||||
if depth == 0 and not schemas:
|
||||
print(" ⚠️ 하위 페이지를 찾을 수 없습니다.")
|
||||
print(f" 💡 노션에서 부모 페이지 하위에 스키마 페이지를 추가하세요.")
|
||||
|
||||
return schemas
|
||||
|
||||
except Exception as e:
|
||||
print(f"{indent}❌ 탐색 실패: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {}
|
||||
|
||||
|
||||
def check_page_has_table(page_id):
|
||||
"""
|
||||
페이지에 테이블 블록이 있는지 확인
|
||||
|
||||
Args:
|
||||
page_id: 확인할 페이지 ID
|
||||
|
||||
반환:
|
||||
True: 테이블 있음
|
||||
False: 테이블 없음
|
||||
"""
|
||||
try:
|
||||
blocks_response = notion.blocks.children.list(block_id=page_id)
|
||||
blocks = blocks_response.get('results', [])
|
||||
|
||||
for block in blocks:
|
||||
if block.get('type') == 'table':
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
# 에러 발생 시 테이블 없음으로 간주
|
||||
return False
|
||||
|
||||
def parse_condition(condition_str):
|
||||
"""
|
||||
사용 조건 파싱
|
||||
|
||||
빈 문자열 → None (항상 사용)
|
||||
"tower_type=attack" → {'field': 'tower_type', 'op': '=', 'value': 'attack'}
|
||||
"""
|
||||
if not condition_str or condition_str.strip() == "":
|
||||
return None
|
||||
|
||||
condition_str = condition_str.strip()
|
||||
|
||||
# 단순 조건: "tower_type=attack"
|
||||
match = re.match(r'(\w+)\s*(=|!=|>|<|>=|<=)\s*(.+)', condition_str)
|
||||
if match:
|
||||
return {
|
||||
'field': match.group(1),
|
||||
'op': match.group(2),
|
||||
'value': match.group(3).strip()
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_notion_table(page_id):
|
||||
"""노션 테이블 파싱"""
|
||||
|
||||
if not notion:
|
||||
raise ValueError("Notion API 클라이언트가 초기화되지 않았습니다.")
|
||||
|
||||
try:
|
||||
# 1. 블록 가져오기
|
||||
blocks_response = notion.blocks.children.list(block_id=page_id)
|
||||
blocks = blocks_response.get('results', [])
|
||||
|
||||
# 2. 테이블 찾기
|
||||
table_block = None
|
||||
for block in blocks:
|
||||
if block.get('type') == 'table':
|
||||
table_block = block
|
||||
break
|
||||
|
||||
if not table_block:
|
||||
raise ValueError(f"테이블을 찾을 수 없습니다.")
|
||||
|
||||
print(f" 📋 테이블 발견")
|
||||
|
||||
# 3. 행 가져오기
|
||||
table_id = table_block['id']
|
||||
rows_response = notion.blocks.children.list(block_id=table_id)
|
||||
rows = rows_response.get('results', [])
|
||||
|
||||
if len(rows) < 2:
|
||||
raise ValueError("테이블에 데이터가 없습니다.")
|
||||
|
||||
# 4. 파싱
|
||||
schema = []
|
||||
|
||||
def extract_text(cell, preserve_newlines=False):
|
||||
"""
|
||||
셀에서 텍스트 추출
|
||||
|
||||
Args:
|
||||
cell: 노션 셀 데이터
|
||||
preserve_newlines: True면 줄바꿈 보존, False면 공백으로 변환
|
||||
"""
|
||||
if not cell or len(cell) == 0:
|
||||
return ""
|
||||
|
||||
text_parts = []
|
||||
for content in cell:
|
||||
if content.get('type') == 'text':
|
||||
text_content = content.get('text', {}).get('content', '')
|
||||
text_parts.append(text_content)
|
||||
|
||||
if preserve_newlines:
|
||||
# 줄바꿈 보존 (\\n으로 이스케이프)
|
||||
result = ''.join(text_parts)
|
||||
# CSV에서 안전하게 저장하기 위해 실제 줄바꿈을 \\n으로 변환
|
||||
result = result.replace('\n', '\\n')
|
||||
return result.strip()
|
||||
else:
|
||||
# 줄바꿈을 공백으로 변환
|
||||
return ''.join(text_parts).strip()
|
||||
|
||||
for row_idx, row in enumerate(rows[1:], start=2):
|
||||
if row.get('type') != 'table_row':
|
||||
continue
|
||||
|
||||
cells = row['table_row']['cells']
|
||||
|
||||
# 4개 컬럼: 필드명, 타입, 사용 조건, 설명
|
||||
field_name = extract_text(cells[0]) if len(cells) > 0 else ""
|
||||
field_type = extract_text(cells[1]) if len(cells) > 1 else "string"
|
||||
condition_str = extract_text(cells[2]) if len(cells) > 2 else ""
|
||||
# ⭐ 설명 컬럼만 줄바꿈 보존
|
||||
description = extract_text(cells[3], preserve_newlines=True) if len(cells) > 3 else ""
|
||||
|
||||
if not field_name:
|
||||
continue
|
||||
|
||||
# 조건 파싱
|
||||
condition = parse_condition(condition_str)
|
||||
|
||||
if condition:
|
||||
print(f" 📌 {field_name}: {condition['field']}={condition['value']}일 때 사용")
|
||||
|
||||
schema.append({
|
||||
'name': field_name,
|
||||
'type': field_type.lower(),
|
||||
'condition': condition,
|
||||
'description': description
|
||||
})
|
||||
|
||||
if len(schema) == 0:
|
||||
raise ValueError("파싱된 스키마가 비어있습니다.")
|
||||
|
||||
return schema
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 파싱 오류: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def get_default_value(field_type, has_condition):
|
||||
"""
|
||||
기본값 결정
|
||||
|
||||
조건부 필드 → None (빈 칸)
|
||||
공통 필드 → 타입별 기본값
|
||||
"""
|
||||
# 조건부 필드는 빈 칸
|
||||
if has_condition:
|
||||
return None
|
||||
|
||||
# 공통 필드는 타입별 기본값
|
||||
if field_type == "int":
|
||||
return 0
|
||||
elif field_type in ["float", "number"]:
|
||||
return 0.0
|
||||
elif field_type in ["bool", "boolean"]:
|
||||
return False
|
||||
elif field_type == "string":
|
||||
return ""
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def merge_schema_and_data(schema, existing_data):
|
||||
"""스키마와 데이터 병합"""
|
||||
|
||||
schema_columns = [f['name'] for f in schema]
|
||||
|
||||
if existing_data is None or existing_data.empty:
|
||||
print(" 새 파일 생성")
|
||||
example_row = {}
|
||||
for field in schema:
|
||||
has_condition = field.get('condition') is not None
|
||||
example_row[field['name']] = get_default_value(field['type'], has_condition)
|
||||
return pd.DataFrame([example_row])
|
||||
|
||||
print(f" 기존 데이터: {len(existing_data)}행")
|
||||
new_df = pd.DataFrame()
|
||||
|
||||
for field in schema:
|
||||
col_name = field['name']
|
||||
if col_name in existing_data.columns:
|
||||
print(f" ✓ {col_name}: 유지")
|
||||
new_df[col_name] = existing_data[col_name]
|
||||
else:
|
||||
has_condition = field.get('condition') is not None
|
||||
default_val = get_default_value(field['type'], has_condition)
|
||||
|
||||
if default_val is None:
|
||||
print(f" + {col_name}: 추가 (조건부 필드, 빈 칸)")
|
||||
else:
|
||||
print(f" + {col_name}: 추가 (기본값: {default_val})")
|
||||
|
||||
new_df[col_name] = default_val
|
||||
|
||||
return new_df
|
||||
|
||||
|
||||
def sync_single_schema(data_name, page_id):
|
||||
"""단일 스키마 동기화 (CSV 버전)"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"📋 {data_name} 동기화")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
# 1. 스키마 읽기
|
||||
print("1️⃣ 스키마 읽기...")
|
||||
schema = parse_notion_table(page_id)
|
||||
print(f" ✅ {len(schema)}개 필드")
|
||||
|
||||
# 2. 스키마를 JSON으로 저장 (검증용)
|
||||
import json
|
||||
schema_json_path = GAMEDATA_DIR / f".{data_name}_schema.json"
|
||||
with open(schema_json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(schema, f, ensure_ascii=False, indent=2)
|
||||
print(f" 💾 스키마 저장: {schema_json_path.name}")
|
||||
|
||||
# 3. 기존 파일 확인
|
||||
csv_path = GAMEDATA_DIR / f"{data_name}.csv"
|
||||
print(f"\n2️⃣ 기존 파일: {csv_path}")
|
||||
|
||||
existing_data = None
|
||||
if csv_path.exists():
|
||||
# 백업
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = BACKUP_DIR / f"{data_name}_{timestamp}.csv"
|
||||
import shutil
|
||||
shutil.copy2(csv_path, backup_path)
|
||||
print(f" 💾 백업: {backup_path.name}")
|
||||
|
||||
existing_data = pd.read_csv(csv_path)
|
||||
|
||||
# 4. 병합
|
||||
print(f"\n3️⃣ 병합 중...")
|
||||
merged_df = merge_schema_and_data(schema, existing_data)
|
||||
|
||||
# 5. 저장 (CSV)
|
||||
print(f"\n4️⃣ 저장...")
|
||||
merged_df.to_csv(csv_path, index=False, encoding='utf-8-sig')
|
||||
print(f" ✅ 완료: {csv_path}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 오류: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("🔄 Notion → CSV 동기화 (자동 발견)")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
if not NOTION_API_KEY:
|
||||
print("❌ NOTION_API_KEY 환경변수가 없습니다")
|
||||
print("💡 설정 방법:")
|
||||
print(' $env:NOTION_API_KEY = "your_key"')
|
||||
return
|
||||
|
||||
print(f"📂 데이터 폴더: {GAMEDATA_DIR}")
|
||||
print(f"💾 백업 폴더: {BACKUP_DIR}")
|
||||
print()
|
||||
|
||||
# ⭐ 스키마 자동 발견
|
||||
try:
|
||||
SCHEMA_PAGE_IDS = discover_schema_pages()
|
||||
except Exception as e:
|
||||
print(f"\n❌ 스키마 발견 실패: {e}")
|
||||
return
|
||||
|
||||
if not SCHEMA_PAGE_IDS:
|
||||
print("\n❌ 발견된 스키마 페이지가 없습니다.")
|
||||
return
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("동기화할 스키마를 선택하세요:")
|
||||
|
||||
schemas = list(SCHEMA_PAGE_IDS.keys())
|
||||
for idx, name in enumerate(schemas, 1):
|
||||
print(f" {idx}. {name}")
|
||||
print(f" {len(schemas) + 1}. 전체")
|
||||
print()
|
||||
|
||||
try:
|
||||
choice = input("선택 (번호 입력): ").strip()
|
||||
|
||||
if choice == str(len(schemas) + 1):
|
||||
selected = schemas
|
||||
else:
|
||||
idx = int(choice) - 1
|
||||
if 0 <= idx < len(schemas):
|
||||
selected = [schemas[idx]]
|
||||
else:
|
||||
print("❌ 잘못된 선택입니다.")
|
||||
return
|
||||
|
||||
except (ValueError, KeyboardInterrupt):
|
||||
print("\n⚠️ 취소되었습니다.")
|
||||
return
|
||||
|
||||
# 동기화 실행
|
||||
print()
|
||||
success_count = 0
|
||||
|
||||
for schema_name in selected:
|
||||
page_id = SCHEMA_PAGE_IDS[schema_name]
|
||||
|
||||
if sync_single_schema(schema_name, page_id):
|
||||
success_count += 1
|
||||
|
||||
# 최종 결과
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"✅ 완료: {success_count}/{len(selected)} 성공")
|
||||
print("=" * 60)
|
||||
|
||||
if success_count > 0:
|
||||
print()
|
||||
print("💡 다음 단계:")
|
||||
print(" 1. GameData 폴더에서 CSV 파일 확인")
|
||||
print(" 2. 데이터 수정")
|
||||
print(" 3. Git 커밋:")
|
||||
print(" git add GameData/*.csv")
|
||||
print(' git commit -m "Update data from Notion"')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
153
DataTools/validate_data.py
Normal file
153
DataTools/validate_data.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# DataTools/validate_data.py
|
||||
"""CSV 데이터 검증 (조건부 필드 지원)"""
|
||||
|
||||
import pandas as pd
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
GAMEDATA_DIR = Path(__file__).parent.parent / "GameData"
|
||||
|
||||
|
||||
def load_schema(data_name):
|
||||
"""저장된 스키마 JSON 로드"""
|
||||
schema_path = GAMEDATA_DIR / f".{data_name}_schema.json"
|
||||
|
||||
if not schema_path.exists():
|
||||
return None
|
||||
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def check_condition(row, condition):
|
||||
"""
|
||||
조건 확인
|
||||
|
||||
condition: {
|
||||
'field': 'tower_type',
|
||||
'op': '=',
|
||||
'value': 'attack'
|
||||
}
|
||||
"""
|
||||
if not condition:
|
||||
return True # 조건 없으면 항상 참
|
||||
|
||||
field = condition['field']
|
||||
op = condition['op']
|
||||
expected = condition['value']
|
||||
|
||||
if field not in row or pd.isna(row[field]):
|
||||
return False
|
||||
|
||||
actual = str(row[field])
|
||||
|
||||
if op == '=':
|
||||
return actual == expected
|
||||
elif op == '!=':
|
||||
return actual != expected
|
||||
elif op == '>':
|
||||
try:
|
||||
return float(row[field]) > float(expected)
|
||||
except:
|
||||
return False
|
||||
elif op == '<':
|
||||
try:
|
||||
return float(row[field]) < float(expected)
|
||||
except:
|
||||
return False
|
||||
elif op == '>=':
|
||||
try:
|
||||
return float(row[field]) >= float(expected)
|
||||
except:
|
||||
return False
|
||||
elif op == '<=':
|
||||
try:
|
||||
return float(row[field]) <= float(expected)
|
||||
except:
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def validate_file(file_path, schema):
|
||||
"""파일 검증 (CSV 버전)"""
|
||||
try:
|
||||
df = pd.read_csv(file_path)
|
||||
errors = []
|
||||
|
||||
if len(df) == 0:
|
||||
errors.append("데이터가 없습니다")
|
||||
return errors
|
||||
|
||||
# 조건부 필드 검증
|
||||
if schema:
|
||||
for field in schema:
|
||||
condition = field.get('condition')
|
||||
|
||||
if not condition:
|
||||
continue # 공통 필드는 스킵
|
||||
|
||||
field_name = field['name']
|
||||
|
||||
# 각 행 검사
|
||||
for idx, row in df.iterrows():
|
||||
should_have_value = check_condition(row, condition)
|
||||
has_value = not pd.isna(row.get(field_name))
|
||||
|
||||
if should_have_value and not has_value:
|
||||
cond_desc = f"{condition['field']}{condition['op']}{condition['value']}"
|
||||
errors.append(
|
||||
f"행 {idx+2}: '{field_name}' 필드가 비어있습니다 "
|
||||
f"(조건: {cond_desc})"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
except Exception as e:
|
||||
return [f"파일 읽기 오류: {e}"]
|
||||
|
||||
|
||||
def main():
|
||||
print("🔍 CSV 데이터 검증 중...\n")
|
||||
|
||||
all_valid = True
|
||||
csv_files = list(GAMEDATA_DIR.glob("*.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("⚠️ 검증할 CSV 파일이 없습니다")
|
||||
return
|
||||
|
||||
for csv_path in csv_files:
|
||||
data_name = csv_path.stem
|
||||
|
||||
# 숨김 파일 스킵
|
||||
if data_name.startswith("."):
|
||||
continue
|
||||
|
||||
print(f"📊 {data_name}.csv 검증...")
|
||||
|
||||
schema = load_schema(data_name)
|
||||
errors = validate_file(csv_path, schema)
|
||||
|
||||
if errors:
|
||||
print(f"❌ 실패:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
all_valid = False
|
||||
else:
|
||||
row_count = len(pd.read_csv(csv_path))
|
||||
print(f"✅ 통과 ({row_count}개 행)")
|
||||
|
||||
print()
|
||||
|
||||
if all_valid:
|
||||
print("🎉 모든 데이터 검증 통과!")
|
||||
else:
|
||||
print("❌ 검증 실패한 파일이 있습니다")
|
||||
|
||||
sys.exit(0 if all_valid else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
README.md
39
README.md
@@ -1,3 +1,38 @@
|
||||
# ProjectMD
|
||||
# 🔧 개발 환경 설정 (Windows)
|
||||
|
||||
ProjectMD
|
||||
## 필수 요구사항
|
||||
- Python 3.7+ ([다운로드](https://www.python.org/downloads/))
|
||||
- Unity 2022.3+
|
||||
- Git for Windows
|
||||
|
||||
## Git Hooks 설치
|
||||
```batch
|
||||
REM PowerShell 실행 정책 설정 (최초 1회)
|
||||
powershell -Command "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser"
|
||||
|
||||
REM Hook 설치
|
||||
setup-hooks.bat
|
||||
```
|
||||
|
||||
## 데이터 작업 흐름
|
||||
|
||||
### 스키마 변경 시
|
||||
1. **스키마 변경** Notion에서 스키마 페이지 수정
|
||||
2. **프로그램 실행** `sync-from-notion.ps1` 우클릭 -> PowerShell에서 실행
|
||||
|
||||
### 데이터 변경 시
|
||||
1. **Excel 수정**: `GameData\Towers.xlsx` 편집
|
||||
2. **커밋**: git 커밋
|
||||
- ✅ 자동 검증
|
||||
- ✅ ScriptableObject 자동 생성
|
||||
3. **Push**: git 푸시
|
||||
|
||||
## 문제 해결
|
||||
|
||||
**Hook이 실행되지 않는 경우:**
|
||||
```batch
|
||||
setup-hooks.bat
|
||||
```
|
||||
|
||||
**Unity 경로 오류:**
|
||||
`git-hooks\post-commit.ps1`에서 Unity 경로 수정
|
||||
5
git-hooks/post-commit
Normal file
5
git-hooks/post-commit
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# git-hooks/post-commit
|
||||
|
||||
powershell.exe -ExecutionPolicy Bypass -File "./git-hooks/post-commit.ps1"
|
||||
exit $?
|
||||
147
git-hooks/post-commit.ps1
Normal file
147
git-hooks/post-commit.ps1
Normal file
@@ -0,0 +1,147 @@
|
||||
# git-hooks/post-commit.ps1
|
||||
|
||||
# 자동 커밋인지 확인 (무한 루프 방지)
|
||||
$commitMsg = git log -1 --pretty=%B
|
||||
if ($commitMsg -match "^\[AUTO\]") {
|
||||
Write-Host "ℹ️ Auto-generated commit detected. Skipping post-commit hook." -ForegroundColor Gray
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "🔄 Post-commit: Generating ScriptableObjects..." -ForegroundColor Cyan
|
||||
|
||||
# 이전 커밋에서 변경된 CSV 파일 확인
|
||||
$changedFiles = git diff-tree --no-commit-id --name-only -r HEAD
|
||||
$csvFiles = $changedFiles | Where-Object { $_ -match "GameData/.*\.csv$" }
|
||||
|
||||
if ($csvFiles.Count -eq 0) {
|
||||
Write-Host "ℹ️ 변경된 CSV 파일이 없습니다. SO 생성 스킵." -ForegroundColor Gray
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "📊 처리할 파일:" -ForegroundColor Yellow
|
||||
$csvFiles | ForEach-Object { Write-Host " - $_" }
|
||||
Write-Host ""
|
||||
|
||||
# Unity 실행 중 확인
|
||||
$unityProcess = Get-Process -Name "Unity" -ErrorAction SilentlyContinue
|
||||
if ($unityProcess) {
|
||||
Write-Host "⚠️ Unity가 실행 중입니다." -ForegroundColor Yellow
|
||||
Write-Host "💡 Unity를 종료하거나, Unity에서 직접 'GameData > Import All Data'를 실행해주세요." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "또는 다음 명령어를 실행하세요:" -ForegroundColor Cyan
|
||||
Write-Host " git commit --amend --no-edit && git reset HEAD~1" -ForegroundColor White
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Unity 경로 찾기 (Unity Hub 기본 설치 경로)
|
||||
$unityVersions = @(
|
||||
"C:\Program Files\Unity\Hub\Editor\2022.3.10f1\Editor\Unity.exe",
|
||||
"C:\Program Files\Unity\Hub\Editor\2023.2.0f1\Editor\Unity.exe"
|
||||
# 필요한 버전 추가
|
||||
)
|
||||
|
||||
$unityPath = $null
|
||||
foreach ($path in $unityVersions) {
|
||||
if (Test-Path $path) {
|
||||
$unityPath = $path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Unity Hub에서 설치된 모든 버전 검색
|
||||
if (-not $unityPath) {
|
||||
$hubPath = "C:\Program Files\Unity\Hub\Editor"
|
||||
if (Test-Path $hubPath) {
|
||||
$editors = Get-ChildItem -Path $hubPath -Directory |
|
||||
ForEach-Object { Join-Path $_.FullName "Editor\Unity.exe" } |
|
||||
Where-Object { Test-Path $_ }
|
||||
|
||||
if ($editors.Count -gt 0) {
|
||||
$unityPath = $editors[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $unityPath) {
|
||||
Write-Host "❌ Unity 실행 파일을 찾을 수 없습니다." -ForegroundColor Red
|
||||
Write-Host "💡 Unity를 수동으로 열고 'GameData > Import All Data'를 실행한 후," -ForegroundColor Yellow
|
||||
Write-Host " 변경된 SO를 커밋해주세요." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "🎮 Unity CLI로 SO 생성 중..." -ForegroundColor Cyan
|
||||
Write-Host "Unity: $unityPath" -ForegroundColor Gray
|
||||
|
||||
$projectPath = (Get-Location).Path + "\UnityProject"
|
||||
$logFile = (Get-Location).Path + "\DataTools\unity_import.log"
|
||||
|
||||
# Unity Batch Mode 실행
|
||||
$unityArgs = @(
|
||||
"-quit",
|
||||
"-batchmode",
|
||||
"-nographics",
|
||||
"-projectPath", "`"$projectPath`"",
|
||||
"-executeMethod", "DataImporter.ImportAllDataCLI",
|
||||
"-logFile", "`"$logFile`""
|
||||
)
|
||||
|
||||
$process = Start-Process -FilePath $unityPath -ArgumentList $unityArgs -Wait -PassThru -NoNewWindow
|
||||
|
||||
if ($process.ExitCode -ne 0) {
|
||||
Write-Host "❌ SO 생성 실패!" -ForegroundColor Red
|
||||
Write-Host "💡 로그 파일을 확인하세요: $logFile" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✅ SO 생성 완료!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# 변경된 SO 파일 확인
|
||||
git add UnityProject/Assets/Data/ScriptableObjects/ 2>$null
|
||||
$status = git status --porcelain UnityProject/Assets/Data/ScriptableObjects/
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($status)) {
|
||||
Write-Host "ℹ️ 변경된 SO 파일이 없습니다." -ForegroundColor Gray
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "📦 변경된 SO:" -ForegroundColor Yellow
|
||||
git status --porcelain UnityProject/Assets/Data/ScriptableObjects/ | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host ""
|
||||
|
||||
# SO 파일 자동 스테이징 및 커밋
|
||||
Write-Host "🚀 SO 파일 자동 커밋 중..." -ForegroundColor Cyan
|
||||
|
||||
# Meta 파일도 함께 추가
|
||||
git add UnityProject/Assets/Data/ScriptableObjects/**/*.meta 2>$null
|
||||
|
||||
# 변경 파일 목록
|
||||
$changedList = $csvFiles -join "`n"
|
||||
|
||||
# 자동 커밋
|
||||
$commitHash = git rev-parse --short HEAD
|
||||
$commitMessage = @"
|
||||
[AUTO] Update ScriptableObjects from CSV
|
||||
|
||||
Generated from commit: $commitHash
|
||||
Changed files:
|
||||
$changedList
|
||||
"@
|
||||
|
||||
git commit -m $commitMessage 2>$null
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "✅ SO 자동 커밋 완료!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
$currentBranch = git branch --show-current
|
||||
Write-Host "💡 다음 명령어로 Push할 수 있습니다:" -ForegroundColor Cyan
|
||||
Write-Host " git push origin $currentBranch" -ForegroundColor White
|
||||
} else {
|
||||
Write-Host "❌ SO 자동 커밋 실패!" -ForegroundColor Red
|
||||
Write-Host "💡 수동으로 SO를 커밋해주세요:" -ForegroundColor Yellow
|
||||
Write-Host " git add UnityProject/Assets/Data/ScriptableObjects/" -ForegroundColor White
|
||||
Write-Host " git commit -m 'Update SO'" -ForegroundColor White
|
||||
}
|
||||
|
||||
exit 0
|
||||
6
git-hooks/pre-commit
Normal file
6
git-hooks/pre-commit
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
# git-hooks/pre-commit
|
||||
# Git이 실행하는 파일 (확장자 없음, sh 헤더 필요)
|
||||
|
||||
powershell.exe -ExecutionPolicy Bypass -File "./git-hooks/pre-commit.ps1"
|
||||
exit $?
|
||||
49
git-hooks/pre-commit.ps1
Normal file
49
git-hooks/pre-commit.ps1
Normal file
@@ -0,0 +1,49 @@
|
||||
# git-hooks/pre-commit.ps1
|
||||
|
||||
Write-Host "🔍 Pre-commit: Validating game data..." -ForegroundColor Cyan
|
||||
|
||||
# 변경된 CSV 파일 확인
|
||||
$changedFiles = git diff --cached --name-only --diff-filter=ACM
|
||||
$CSVFiles = $changedFiles | Where-Object { $_ -match "GameData/.*\.csv$" }
|
||||
|
||||
if ($CSVFiles.Count -eq 0) {
|
||||
Write-Host "ℹ️ 변경된 CSV 파일이 없습니다." -ForegroundColor Gray
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "📊 검증할 파일:" -ForegroundColor Yellow
|
||||
$CSVFiles | ForEach-Object { Write-Host " - $_" }
|
||||
Write-Host ""
|
||||
|
||||
# Python 설치 확인
|
||||
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
|
||||
if (-not $pythonCmd) {
|
||||
Write-Host "❌ Python이 설치되어 있지 않습니다." -ForegroundColor Red
|
||||
Write-Host "💡 https://www.python.org/downloads/ 에서 Python을 설치하세요." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 검증 스크립트 실행
|
||||
Push-Location DataTools
|
||||
|
||||
try {
|
||||
python validate_data.py
|
||||
$exitCode = $LASTEXITCODE
|
||||
|
||||
Pop-Location
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host ""
|
||||
Write-Host "❌ 데이터 검증 실패!" -ForegroundColor Red
|
||||
Write-Host "💡 CSV 파일을 수정 후 다시 커밋해주세요." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✅ 데이터 검증 통과!" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Pop-Location
|
||||
Write-Host "❌ 검증 중 오류 발생: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
35
setup-hooks.ps1
Normal file
35
setup-hooks.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
# setup-hooks.ps1
|
||||
|
||||
Write-Host "🔧 Setting up Git hooks..." -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# .git/hooks 디렉토리 존재 확인
|
||||
if (-not (Test-Path ".git\hooks")) {
|
||||
Write-Host "❌ .git\hooks 디렉토리를 찾을 수 없습니다." -ForegroundColor Red
|
||||
Write-Host "💡 Git 저장소 루트에서 실행하세요." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# hooks 복사
|
||||
Write-Host "📋 Copying hook files..." -ForegroundColor Yellow
|
||||
|
||||
try {
|
||||
Copy-Item -Path "git-hooks\pre-commit" -Destination ".git\hooks\pre-commit" -Force
|
||||
Copy-Item -Path "git-hooks\post-commit" -Destination ".git\hooks\post-commit" -Force
|
||||
|
||||
Write-Host "✅ Git hooks installed!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Installed hooks:" -ForegroundColor Cyan
|
||||
Write-Host " - pre-commit: XLSX 데이터 검증" -ForegroundColor White
|
||||
Write-Host " - post-commit: ScriptableObject 자동 생성" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "💡 사용법:" -ForegroundColor Cyan
|
||||
Write-Host " 1. Excel에서 데이터 수정" -ForegroundColor White
|
||||
Write-Host " 2. git add GameData/Towers.xlsx" -ForegroundColor White
|
||||
Write-Host " 3. git commit -m 'Update data'" -ForegroundColor White
|
||||
Write-Host " → 자동으로 검증 및 SO 생성" -ForegroundColor Gray
|
||||
}
|
||||
catch {
|
||||
Write-Host "❌ Hook 파일 복사 실패: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
91
sync-from-notion.ps1
Normal file
91
sync-from-notion.ps1
Normal file
@@ -0,0 +1,91 @@
|
||||
# sync-from-notion.ps1
|
||||
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host "🔄 Notion → Excel → C# 클래스 통합 동기화" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Python 확인
|
||||
if (-not (Get-Command python -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "❌ Python이 설치되어 있지 않습니다." -ForegroundColor Red
|
||||
Write-Host "https://www.python.org 에서 설치해주세요." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ===== 1. 노션 → Excel =====
|
||||
Write-Host "1️⃣ 노션 스키마 → Excel 동기화..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
Push-Location DataTools
|
||||
|
||||
try {
|
||||
python sync_from_notion.py
|
||||
$exitCode1 = $LASTEXITCODE
|
||||
}
|
||||
catch {
|
||||
Write-Host "❌ 실행 중 오류: $_" -ForegroundColor Red
|
||||
$exitCode1 = 1
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if ($exitCode1 -ne 0) {
|
||||
Write-Host ""
|
||||
Write-Host "❌ Excel 동기화 실패!" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "✅ Excel 동기화 완료" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# ===== 2. C# 클래스 생성 =====
|
||||
Write-Host "2️⃣ C# 클래스 자동 생성..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
Push-Location DataTools
|
||||
|
||||
try {
|
||||
python generate_csharp_classes.py
|
||||
$exitCode2 = $LASTEXITCODE
|
||||
}
|
||||
catch {
|
||||
Write-Host "❌ 실행 중 오류: $_" -ForegroundColor Red
|
||||
$exitCode2 = 1
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if ($exitCode2 -ne 0) {
|
||||
Write-Host ""
|
||||
Write-Host "❌ C# 클래스 생성 실패!" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "✅ C# 클래스 생성 완료" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# ===== 완료 =====
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host "🎉 모든 작업 완료!" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "📋 다음 단계:" -ForegroundColor Cyan
|
||||
Write-Host " 1. GameData 폴더에서 Excel 파일 확인" -ForegroundColor White
|
||||
Write-Host " 2. Excel에서 데이터 수정" -ForegroundColor White
|
||||
Write-Host " 3. Unity 열기" -ForegroundColor White
|
||||
Write-Host " 4. DigAndDefend → Data Importer 실행" -ForegroundColor White
|
||||
Write-Host " 5. ScriptableObject 생성 확인" -ForegroundColor White
|
||||
Write-Host ""
|
||||
|
||||
pause
|
||||
Reference in New Issue
Block a user