데이터 파이프라인 이식

This commit is contained in:
2026-01-24 08:41:22 +09:00
parent 84d4decd3a
commit fb6570a992
20 changed files with 3277 additions and 0 deletions

File diff suppressed because it is too large Load Diff

8
Assets/Data.meta Normal file
View File

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

8
Assets/Data/Scripts.meta Normal file
View File

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

View File

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

8
Assets/Editor.meta Normal file
View File

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

View File

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

View File

@@ -0,0 +1,94 @@
// Assets/Editor/DataImporter/CSVDebugger.cs
using UnityEngine;
using UnityEditor;
using System.IO;
using System.Text;
namespace Northbound.Editor
{
public static class CSVDebugger
{
[MenuItem("Northbound/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("=== 디버그 완료 ===");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95247e4274c204446ac0f9c45c334c56

View 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 Northbound.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 = $"Northbound.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}";
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 79f372743463a5e4cb3a9b915de9bebe

View File

@@ -0,0 +1,122 @@
// Assets/Editor/DataImporter/ImporterWindow.cs
using UnityEngine;
using UnityEditor;
using System.IO;
using System.Linq;
namespace Northbound.Editor
{
public class ImporterWindow : EditorWindow
{
private Vector2 scrollPosition;
[MenuItem("Northbound/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);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 017c665c9c855fa43b55f1d61c238903

View File

@@ -0,0 +1,258 @@
# DataTools/generate_csharp_classes.py
"""노션 스키마 → Unity C# ScriptableObject 클래스 생성"""
import json
from pathlib import Path
from collections import defaultdict
GAMEDATA_DIR = Path(__file__).parent.parent / "GameData"
UNITY_OUTPUT_DIR = Path(__file__).parent.parent / "Assets" / "Data" / "Scripts" / "DataClasses"
def get_csharp_type(field_type):
"""Python 타입 → C# 타입 변환"""
type_map = {
'int': 'int',
'float': 'float',
'number': 'float',
'string': 'string',
'bool': 'bool',
'boolean': 'bool'
}
return type_map.get(field_type.lower(), 'string')
def to_camel_case(snake_str):
"""
snake_case → camelCase
예시:
- tower_type → towerType
- damage → damage
- attack_speed → attackSpeed
"""
components = snake_str.split('_')
return components[0] + ''.join(x.title() for x in components[1:])
def to_pascal_case(snake_str):
"""
snake_case → PascalCase
예시:
- tower_type → TowerType
- damage → Damage
"""
return ''.join(x.title() for x in snake_str.split('_'))
def group_fields_by_condition(schema):
"""
필드를 조건별로 그룹화
반환:
{
'common': [공통 필드들],
'attack': [attack 타입 필드들],
'defense': [defense 타입 필드들],
...
}
"""
groups = defaultdict(list)
for field in schema:
if field.get('condition'):
condition_value = field['condition']['value']
groups[condition_value].append(field)
else:
groups['common'].append(field)
return groups
def generate_class(schema_name, schema):
"""
스키마 → C# 클래스 코드 생성
schema: [
{'name': 'id', 'type': 'int', 'condition': None, 'description': '...'},
{'name': 'damage', 'type': 'float', 'condition': {...}, 'description': '...'},
...
]
"""
class_name = schema_name + "Data"
# 필드 그룹화
field_groups = group_fields_by_condition(schema)
# 조건 필드명 찾기 (예: tower_type, enemy_type)
condition_field = None
for field in schema:
if field.get('condition'):
condition_field = field['condition']['field']
break
# C# 코드 생성 시작
lines = []
# 파일 헤더
lines.append("// 이 파일은 자동 생성되었습니다. 직접 수정하지 마세요!")
lines.append("// 생성 스크립트: DataTools/generate_csharp_classes.py")
lines.append("")
lines.append("using UnityEngine;")
lines.append("")
lines.append("namespace DigAndDefend.Data")
lines.append("{")
lines.append(f' [CreateAssetMenu(fileName = "{class_name}", menuName = "DigAndDefend/{schema_name} Data")]')
lines.append(f" public class {class_name} : ScriptableObject")
lines.append(" {")
# 공통 필드
if 'common' in field_groups and field_groups['common']:
lines.append(" [Header(\"기본 정보\")]")
for field in field_groups['common']:
add_field(lines, field, nullable=False)
lines.append("")
# 조건부 필드 (그룹별)
for condition_value, fields in field_groups.items():
if condition_value == 'common':
continue
header_name = condition_value.capitalize()
lines.append(f" [Header(\"{header_name} 전용\")]")
for field in fields:
add_field(lines, field, nullable=True)
lines.append("")
# 유틸리티 메서드
if condition_field:
lines.append(" // ===== 유틸리티 메서드 =====")
lines.append("")
# 타입 체크 프로퍼티
for condition_value in field_groups.keys():
if condition_value == 'common':
continue
property_name = f"Is{to_pascal_case(condition_value)}"
field_name = to_camel_case(condition_field)
lines.append(f' public bool {property_name} => {field_name} == "{condition_value}";')
lines.append("")
# Nullable 필드 Getter 메서드
for condition_value, fields in field_groups.items():
if condition_value == 'common':
continue
for field in fields:
field_name = to_camel_case(field['name'])
method_name = f"Get{to_pascal_case(field['name'])}"
field_type = get_csharp_type(field['type'])
# 기본값 결정
if field_type == 'int':
default_value = '0'
elif field_type == 'float':
default_value = '0f'
elif field_type == 'bool':
default_value = 'false'
else:
default_value = '""'
lines.append(f" public {field_type} {method_name}() => {field_name} ?? {default_value};")
# 클래스 종료
lines.append(" }")
lines.append("}")
return "\n".join(lines)
def add_field(lines, field, nullable=False):
"""필드 정의 추가"""
field_name = to_camel_case(field['name'])
field_type = get_csharp_type(field['type'])
# 주석 (설명이 있으면)
if field.get('description'):
# 줄바꿈을 공백으로 변환 (C# 주석은 한 줄)
description = field['description'].replace('\\n', ' ').replace('\n', ' ')
lines.append(f" /// <summary>{description}</summary>")
# 필드 선언
if nullable:
lines.append(f" public {field_type}? {field_name};")
else:
lines.append(f" public {field_type} {field_name};")
def generate_all_classes():
"""모든 스키마에 대해 C# 클래스 생성"""
# 출력 폴더 생성
UNITY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# 스키마 파일 찾기
schema_files = list(GAMEDATA_DIR.glob(".*_schema.json"))
if not schema_files:
print("⚠️ 스키마 파일을 찾을 수 없습니다.")
print("💡 먼저 sync_from_notion.py를 실행하세요.")
return
print("=" * 60)
print("🔧 C# 클래스 자동 생성")
print("=" * 60)
print()
generated_count = 0
for schema_file in schema_files:
# ".Towers_schema.json" → "Towers"
schema_name = schema_file.stem.replace("_schema", "").lstrip(".")
print(f"📋 {schema_name} 처리 중...")
# 스키마 로드
try:
with open(schema_file, 'r', encoding='utf-8') as f:
schema = json.load(f)
except Exception as e:
print(f" ❌ 스키마 로드 실패: {e}")
continue
# C# 코드 생성
try:
csharp_code = generate_class(schema_name, schema)
except Exception as e:
print(f" ❌ 코드 생성 실패: {e}")
import traceback
traceback.print_exc()
continue
# 파일 저장
output_file = UNITY_OUTPUT_DIR / f"{schema_name}Data.cs"
try:
with open(output_file, 'w', encoding='utf-8') as f:
f.write(csharp_code)
print(f" ✅ 생성: {schema_name}Data.cs ({len(schema)}개 필드)")
generated_count += 1
except Exception as e:
print(f" ❌ 파일 저장 실패: {e}")
print()
print("=" * 60)
print(f"🎉 완료: {generated_count}개 클래스 생성")
print(f"📂 위치: {UNITY_OUTPUT_DIR}")
print("=" * 60)
if __name__ == "__main__":
generate_all_classes()

View 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 = "2f194d45b1a380948073ca3883f7347e"
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
View 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()

View File

@@ -1,3 +1,4 @@
<Solution>
<Project Path="Assembly-CSharp.csproj" />
<Project Path="Assembly-CSharp-Editor.csproj" />
</Solution>

6
git-hooks/pre-commit Normal file
View 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
View 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
}

27
setup-hooks.ps1 Normal file
View File

@@ -0,0 +1,27 @@
# 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
Write-Host "✅ Git hooks installed!" -ForegroundColor Green
Write-Host ""
Write-Host "Installed hooks:" -ForegroundColor Cyan
Write-Host " - pre-commit: XLSX 데이터 검증" -ForegroundColor White
}
catch {
Write-Host "❌ Hook 파일 복사 실패: $_" -ForegroundColor Red
exit 1
}

91
sync-from-notion.ps1 Normal file
View 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. Northbound → Data Importer 실행" -ForegroundColor White
Write-Host " 5. ScriptableObject 생성 확인" -ForegroundColor White
Write-Host ""
pause