콘텐츠로 이동

Unity PropertyAttribute와 커스텀 에디터 확장

Unity 에디터 확장은 PropertyAttribute + PropertyDrawer로 개별 필드 UI를 바꾸거나, CustomEditor로 컴포넌트 전체 인스펙터를 재정의합니다. UI Toolkit(UIElements) 기반으로 전환하면 UXML/USS로 복잡한 에디터 UI를 선언적으로 구성할 수 있습니다.


// Runtime 코드
public class RangeStepAttribute : PropertyAttribute
{
public float Min, Max, Step;
public RangeStepAttribute(float min, float max, float step = 0.1f)
{
Min = min;
Max = max;
Step = step;
}
}
// 사용
public class PlayerStats : MonoBehaviour
{
[RangeStep(0f, 100f, 5f)]
public float Health = 100f;
[RangeStep(0f, 50f, 1f)]
public float Speed = 10f;
}

// Editor 폴더 또는 Editor 어셈블리에 배치
using UnityEditor;
using UnityEngine;
[CustomPropertyDrawer(typeof(RangeStepAttribute))]
public class RangeStepDrawer : PropertyDrawer
{
public override void OnGUI(
Rect position,
SerializedProperty property,
GUIContent label)
{
var attr = (RangeStepAttribute)attribute;
if (property.propertyType != SerializedPropertyType.Float)
{
EditorGUI.HelpBox(position,
"RangeStep은 float에만 사용 가능합니다",
MessageType.Error);
return;
}
EditorGUI.BeginProperty(position, label, property);
float val = EditorGUI.Slider(position, label,
property.floatValue, attr.Min, attr.Max);
// Step으로 스냅
val = Mathf.Round(val / attr.Step) * attr.Step;
property.floatValue = val;
EditorGUI.EndProperty();
}
// 높이 재정의 (기본: 1줄)
public override float GetPropertyHeight(
SerializedProperty property, GUIContent label)
{
return EditorGUIUtility.singleLineHeight;
}
}

public class ExampleComponent : MonoBehaviour
{
[Header("전투 설정")]
[Tooltip("초당 공격 횟수")]
[Range(0.1f, 10f)]
public float AttackRate = 1f;
[Space(10)]
[Header("이동 설정")]
public float MoveSpeed = 5f;
[Multiline(3)]
public string Description;
[HideInInspector]
public int InternalId;
[SerializeField] private float _privateField;
// 조건부 표시 (커스텀 구현 필요 또는 Odin Inspector)
[ShowIf("_showAdvanced")] // Odin Inspector
public float AdvancedSetting;
private bool _showAdvanced;
}

4. CustomEditor — 컴포넌트 전체 재정의

섹션 제목: “4. CustomEditor — 컴포넌트 전체 재정의”
[CustomEditor(typeof(WaypointPath))]
public class WaypointPathEditor : Editor
{
private WaypointPath _path;
void OnEnable() => _path = (WaypointPath)target;
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.LabelField("웨이포인트 경로",
EditorStyles.boldLabel);
// 기본 인스펙터
DrawDefaultInspector();
EditorGUILayout.Space();
// 커스텀 버튼
if (GUILayout.Button("웨이포인트 추가"))
{
Undo.RecordObject(_path, "웨이포인트 추가");
_path.AddWaypoint();
}
if (GUILayout.Button("경로 초기화"))
{
if (EditorUtility.DisplayDialog(
"초기화 확인", "모든 웨이포인트를 삭제합니까?", "확인", "취소"))
{
Undo.RecordObject(_path, "경로 초기화");
_path.Clear();
}
}
serializedObject.ApplyModifiedProperties();
}
// 씬 뷰에서 핸들 그리기
void OnSceneGUI()
{
for (int i = 0; i < _path.Waypoints.Count; i++)
{
EditorGUI.BeginChangeCheck();
Vector3 pos = Handles.PositionHandle(
_path.Waypoints[i], Quaternion.identity);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(_path, "웨이포인트 이동");
_path.Waypoints[i] = pos;
}
Handles.Label(_path.Waypoints[i] + Vector3.up * 0.3f,
$"WP {i}");
}
}
}

[CustomPropertyDrawer(typeof(RangeStepAttribute))]
public class RangeStepDrawerUIE : PropertyDrawer
{
public override VisualElement CreatePropertyGUI(
SerializedProperty property)
{
var attr = (RangeStepAttribute)attribute;
var root = new VisualElement();
var slider = new Slider(
property.displayName,
attr.Min, attr.Max)
{
value = property.floatValue,
showInputField = true
};
slider.RegisterValueChangedCallback(evt =>
{
float stepped = Mathf.Round(evt.newValue / attr.Step) * attr.Step;
property.floatValue = stepped;
property.serializedObject.ApplyModifiedProperties();
});
root.Add(slider);
return root;
}
}

public class LevelDesignTools : EditorWindow
{
[MenuItem("Tools/레벨 디자인 도구 %#l")]
public static void ShowWindow() =>
GetWindow<LevelDesignTools>("레벨 도구");
private float _spawnRadius = 5f;
private GameObject _prefab;
void OnGUI()
{
GUILayout.Label("오브젝트 배치 도구", EditorStyles.boldLabel);
_prefab = (GameObject)EditorGUILayout.ObjectField(
"프리팹", _prefab, typeof(GameObject), false);
_spawnRadius = EditorGUILayout.Slider(
"배치 반경", _spawnRadius, 1f, 50f);
if (GUILayout.Button("원형 배치") && _prefab != null)
PlaceInCircle();
}
void PlaceInCircle()
{
int count = 8;
for (int i = 0; i < count; i++)
{
float angle = i * 360f / count;
var dir = Quaternion.Euler(0, angle, 0) * Vector3.forward;
var pos = dir * _spawnRadius;
var go = (GameObject)PrefabUtility.InstantiatePrefab(_prefab);
go.transform.position = pos;
Undo.RegisterCreatedObjectUndo(go, "원형 배치");
}
}
}

간단한 필드 커스터마이징은 PropertyAttribute + PropertyDrawer로 구현하고, 컴포넌트 전체 인스펙터는 CustomEditor로 재정의하세요. 씬 뷰 조작이 필요하면 OnSceneGUI에서 Handles를 사용하고, 반드시 Undo.RecordObject를 호출해 Ctrl+Z가 동작하도록 하세요. 새 에디터 도구는 IMGUI보다 UI Toolkit(VisualElement)으로 구현하면 재사용성과 유지보수성이 높아집니다.