using System.Collections.Generic; using UnityEngine; using UnityEditor; using UnityEngine.UIElements; using UnityEditor.UIElements; using Unity.Cinemachine; [CustomEditor(typeof(CameraManager))] public class CameraManagerEditor : Editor { private const string UxmlPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uxml"; private const string UssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/CameraManagerEditor.uss"; private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss"; private VisualElement presetsSection; private VisualElement presetsContainer; private VisualElement dropZone; private Label presetsTitleLabel; private Button defaultBlendButton; private Label defaultBlendStatusLabel; private HelpBox brainNotFoundWarning; private CameraManager manager; public override VisualElement CreateInspectorGUI() { manager = (CameraManager)target; var root = new VisualElement(); // Stylesheets var commonUss = AssetDatabase.LoadAssetAtPath(CommonUssPath); var uss = AssetDatabase.LoadAssetAtPath(UssPath); if (commonUss != null) root.styleSheets.Add(commonUss); if (uss != null) root.styleSheets.Add(uss); // UXML template var uxml = AssetDatabase.LoadAssetAtPath(UxmlPath); if (uxml != null) uxml.CloneTree(root); // Conditional UI logic SetupZoomValidation(root); SetupFOVValidation(root); SetupRotationTargetLogic(root); SetupBlendTransitionLogic(root); // Cinemachine Brain default blend toggle SetupDefaultBlendSection(root); // Preset list (dynamic) BuildPresetsSection(root); // Play mode active state polling root.schedule.Execute(UpdatePlayModeState).Every(200); // Undo/redo Undo.undoRedoPerformed += OnUndoRedo; root.RegisterCallback(_ => Undo.undoRedoPerformed -= OnUndoRedo); return root; } private void OnUndoRedo() { if (manager == null) return; RebuildPresetList(); } #region Validation private void SetupZoomValidation(VisualElement root) { var warning = root.Q("zoomWarning"); if (warning == null) return; var minProp = serializedObject.FindProperty("minZoomDistance"); var maxProp = serializedObject.FindProperty("maxZoomDistance"); if (minProp == null || maxProp == null) return; void UpdateVisibility(SerializedProperty _) { serializedObject.Update(); bool show = serializedObject.FindProperty("minZoomDistance").floatValue >= serializedObject.FindProperty("maxZoomDistance").floatValue; warning.style.display = show ? DisplayStyle.Flex : DisplayStyle.None; } root.TrackPropertyValue(minProp, UpdateVisibility); root.TrackPropertyValue(maxProp, UpdateVisibility); UpdateVisibility(null); } private void SetupFOVValidation(VisualElement root) { var warning = root.Q("fovWarning"); if (warning == null) return; var minProp = serializedObject.FindProperty("minFOV"); var maxProp = serializedObject.FindProperty("maxFOV"); if (minProp == null || maxProp == null) return; void UpdateVisibility(SerializedProperty _) { serializedObject.Update(); bool show = serializedObject.FindProperty("minFOV").floatValue >= serializedObject.FindProperty("maxFOV").floatValue; warning.style.display = show ? DisplayStyle.Flex : DisplayStyle.None; } root.TrackPropertyValue(minProp, UpdateVisibility); root.TrackPropertyValue(maxProp, UpdateVisibility); UpdateVisibility(null); } private void SetupRotationTargetLogic(VisualElement root) { var targetField = root.Q("manualRotationTargetField"); var info = root.Q("rotationTargetInfo"); if (targetField == null || info == null) return; var useHeadProp = serializedObject.FindProperty("useAvatarHeadAsTarget"); if (useHeadProp == null) return; void UpdateState(SerializedProperty _) { serializedObject.Update(); bool useHead = serializedObject.FindProperty("useAvatarHeadAsTarget").boolValue; targetField.SetEnabled(!useHead); info.style.display = useHead ? DisplayStyle.Flex : DisplayStyle.None; } root.TrackPropertyValue(useHeadProp, UpdateState); UpdateState(null); } private void SetupBlendTransitionLogic(VisualElement root) { var settingsGroup = root.Q("blendSettingsGroup"); var modeInfo = root.Q("blendModeInfo"); var rendererWarning = root.Q("blendRendererWarning"); if (settingsGroup == null || modeInfo == null || rendererWarning == null) return; var useBlendProp = serializedObject.FindProperty("useBlendTransition"); var useRealtimeProp = serializedObject.FindProperty("useRealtimeBlend"); if (useBlendProp == null || useRealtimeProp == null) return; void UpdateState(SerializedProperty _) { serializedObject.Update(); bool useBlend = serializedObject.FindProperty("useBlendTransition").boolValue; bool useRealtime = serializedObject.FindProperty("useRealtimeBlend").boolValue; settingsGroup.SetEnabled(useBlend); rendererWarning.style.display = useBlend ? DisplayStyle.Flex : DisplayStyle.None; if (useBlend) { modeInfo.style.display = DisplayStyle.Flex; modeInfo.text = useRealtime ? "실시간 모드: 블렌딩 중 두 카메라 모두 실시간으로 렌더링됩니다.\n성능 비용이 증가하지만 이전 카메라도 움직입니다." : "스냅샷 모드: 이전 화면을 캡처한 후 블렌딩합니다.\n성능 효율적이지만 이전 화면이 정지합니다."; } else { modeInfo.style.display = DisplayStyle.None; } } root.TrackPropertyValue(useBlendProp, UpdateState); root.TrackPropertyValue(useRealtimeProp, UpdateState); UpdateState(null); } #endregion #region Cinemachine Brain private void SetupDefaultBlendSection(VisualElement root) { var group = root.Q("defaultBlendGroup"); brainNotFoundWarning = root.Q("brainNotFoundWarning"); if (group == null) return; var row = new VisualElement(); row.AddToClassList("blend-toggle-row"); defaultBlendStatusLabel = new Label("현재 Default Blend:"); defaultBlendStatusLabel.AddToClassList("blend-toggle-label"); row.Add(defaultBlendStatusLabel); defaultBlendButton = new Button(ToggleEditorDefaultBlend) { text = "-" }; defaultBlendButton.AddToClassList("blend-toggle-btn"); row.Add(defaultBlendButton); group.Add(row); // 인스펙터가 포커스를 가질 때 주기적으로 UI 동기화 (씬에서 직접 편집한 경우 반영) root.schedule.Execute(UpdateDefaultBlendUI).Every(500); UpdateDefaultBlendUI(); } private CinemachineBrain FindBrain() { var mainCam = Camera.main; if (mainCam != null) { var brain = mainCam.GetComponent(); if (brain != null) return brain; } return FindFirstObjectByType(); } private void UpdateDefaultBlendUI() { if (defaultBlendButton == null) return; var brain = FindBrain(); if (brain == null) { defaultBlendButton.text = "Brain 없음"; defaultBlendButton.SetEnabled(false); defaultBlendButton.RemoveFromClassList("blend-toggle-btn--cut"); defaultBlendButton.RemoveFromClassList("blend-toggle-btn--ease"); if (defaultBlendStatusLabel != null) defaultBlendStatusLabel.text = "CinemachineBrain을 찾을 수 없음"; if (brainNotFoundWarning != null) brainNotFoundWarning.style.display = DisplayStyle.Flex; return; } if (brainNotFoundWarning != null) brainNotFoundWarning.style.display = DisplayStyle.None; var style = brain.DefaultBlend.Style; bool isCut = style == CinemachineBlendDefinition.Styles.Cut; bool isEase = style == CinemachineBlendDefinition.Styles.EaseInOut; if (defaultBlendStatusLabel != null) defaultBlendStatusLabel.text = $"현재 Default Blend: {style}"; defaultBlendButton.SetEnabled(true); defaultBlendButton.text = isCut ? "▶ EaseInOut 으로 전환" : "▶ Cut 으로 전환"; defaultBlendButton.EnableInClassList("blend-toggle-btn--cut", isCut); defaultBlendButton.EnableInClassList("blend-toggle-btn--ease", isEase); } private void ToggleEditorDefaultBlend() { var brain = FindBrain(); if (brain == null) return; Undo.RecordObject(brain, "Toggle CinemachineBrain Default Blend"); var blend = brain.DefaultBlend; blend.Style = (blend.Style == CinemachineBlendDefinition.Styles.Cut) ? CinemachineBlendDefinition.Styles.EaseInOut : CinemachineBlendDefinition.Styles.Cut; brain.DefaultBlend = blend; EditorUtility.SetDirty(brain); UpdateDefaultBlendUI(); } #endregion #region Preset List private void BuildPresetsSection(VisualElement root) { presetsSection = root.Q("presetsSection"); if (presetsSection == null) return; // Header var header = new VisualElement(); header.AddToClassList("presets-header"); presetsTitleLabel = new Label($"카메라 프리셋 ({manager.cameraPresets.Count})"); presetsTitleLabel.AddToClassList("presets-title"); header.Add(presetsTitleLabel); var addBtn = new Button(AddPreset) { text = "+ 프리셋 추가", tooltip = "씬의 첫 CinemachineCamera를 추가합니다. 아래 영역으로 드래그해서 추가할 수도 있습니다." }; addBtn.AddToClassList("preset-add-btn"); header.Add(addBtn); presetsSection.Add(header); // Container presetsContainer = new VisualElement(); presetsSection.Add(presetsContainer); // Drop zone dropZone = new Label("CinemachineCamera를 여기로 드래그하여 추가"); dropZone.AddToClassList("preset-drop-zone"); presetsSection.Add(dropZone); SetupDragAndDrop(presetsSection); RebuildPresetList(); } private void SetupDragAndDrop(VisualElement target) { target.RegisterCallback(evt => { if (GetDraggedCameras().Count > 0) { DragAndDrop.visualMode = DragAndDropVisualMode.Copy; dropZone?.AddToClassList("preset-drop-zone--active"); evt.StopPropagation(); } }); target.RegisterCallback(_ => { dropZone?.RemoveFromClassList("preset-drop-zone--active"); }); target.RegisterCallback(_ => { dropZone?.RemoveFromClassList("preset-drop-zone--active"); }); target.RegisterCallback(evt => { var cams = GetDraggedCameras(); dropZone?.RemoveFromClassList("preset-drop-zone--active"); if (cams.Count == 0) return; DragAndDrop.AcceptDrag(); Undo.RecordObject(this.target, "Add Camera Preset (Drop)"); foreach (var cam in cams) { manager.cameraPresets.Add(new CameraManager.CameraPreset(cam)); } EditorUtility.SetDirty(this.target); RebuildPresetList(); evt.StopPropagation(); }); } private List GetDraggedCameras() { var list = new List(); foreach (var obj in DragAndDrop.objectReferences) { if (obj is CinemachineCamera cc) { if (!list.Contains(cc)) list.Add(cc); } else if (obj is GameObject go) { var cam = go.GetComponent(); if (cam != null && !list.Contains(cam)) list.Add(cam); } } return list; } private void RebuildPresetList() { if (presetsContainer == null || manager == null) return; presetsContainer.Clear(); if (presetsTitleLabel != null) presetsTitleLabel.text = $"카메라 프리셋 ({manager.cameraPresets.Count})"; if (manager.cameraPresets.Count == 0) { var empty = new Label("카메라 프리셋이 없습니다. '+ 프리셋 추가' 버튼을 눌러 추가하세요."); empty.AddToClassList("preset-empty"); presetsContainer.Add(empty); return; } for (int i = 0; i < manager.cameraPresets.Count; i++) { presetsContainer.Add(CreatePresetItem(i)); } } private VisualElement CreatePresetItem(int index) { var preset = manager.cameraPresets[index]; bool isActive = Application.isPlaying && manager.CurrentPreset == preset; var item = new VisualElement(); item.AddToClassList("preset-item"); if (isActive) item.AddToClassList("preset-item--active"); // --- Header row --- var headerRow = new VisualElement(); headerRow.AddToClassList("preset-item-header"); int idx = index; var indexField = new IntegerField { value = index + 1, isDelayed = true, tooltip = "카메라 순서 번호. 숫자를 입력 후 Enter를 누르면 해당 위치로 이동합니다." }; indexField.AddToClassList("preset-index-field"); indexField.RegisterValueChangedCallback(evt => { int newIndex = Mathf.Clamp(evt.newValue - 1, 0, manager.cameraPresets.Count - 1); if (newIndex != idx) { MovePreset(idx, newIndex); } else if (evt.newValue != idx + 1) { indexField.SetValueWithoutNotify(idx + 1); } }); headerRow.Add(indexField); var nameField = new TextField(); nameField.value = preset.presetName; nameField.AddToClassList("preset-name-field"); nameField.RegisterValueChangedCallback(evt => { Undo.RecordObject(target, "Rename Camera Preset"); manager.cameraPresets[idx].presetName = evt.newValue; EditorUtility.SetDirty(target); }); headerRow.Add(nameField); if (isActive) { var activeLabel = new Label("[Active]"); activeLabel.AddToClassList("preset-active-label"); headerRow.Add(activeLabel); } // Up button var upBtn = new Button(() => SwapPresets(index, index - 1)) { text = "\u25B2", tooltip = "위로 이동" }; upBtn.AddToClassList("preset-reorder-btn"); upBtn.SetEnabled(index > 0); headerRow.Add(upBtn); // Down button var downBtn = new Button(() => SwapPresets(index, index + 1)) { text = "\u25BC", tooltip = "아래로 이동" }; downBtn.AddToClassList("preset-reorder-btn"); downBtn.SetEnabled(index < manager.cameraPresets.Count - 1); headerRow.Add(downBtn); // Duplicate button var duplicateBtn = new Button(() => DuplicatePreset(index)) { text = "\u29C9", tooltip = "프리셋 복제" }; duplicateBtn.AddToClassList("preset-duplicate-btn"); headerRow.Add(duplicateBtn); // Delete button var deleteBtn = new Button(() => DeletePreset(index)) { text = "X", tooltip = "프리셋 삭제" }; deleteBtn.AddToClassList("preset-delete-btn"); headerRow.Add(deleteBtn); item.Add(headerRow); // --- Property fields --- var fields = new VisualElement(); fields.AddToClassList("preset-fields"); var cameraField = new ObjectField("가상 카메라") { objectType = typeof(CinemachineCamera), allowSceneObjects = true, value = preset.virtualCamera }; cameraField.RegisterValueChangedCallback(evt => { Undo.RecordObject(target, "Change Camera Preset Camera"); manager.cameraPresets[idx].virtualCamera = evt.newValue as CinemachineCamera; EditorUtility.SetDirty(target); }); fields.Add(cameraField); var mouseToggle = new Toggle("마우스 조작 허용") { tooltip = "이 카메라에서 마우스 조작(회전, 팬, 줌)을 허용할지 여부", value = preset.allowMouseControl }; mouseToggle.RegisterValueChangedCallback(evt => { Undo.RecordObject(target, "Toggle Mouse Control"); manager.cameraPresets[idx].allowMouseControl = evt.newValue; EditorUtility.SetDirty(target); }); fields.Add(mouseToggle); item.Add(fields); return item; } private void AddPreset() { var newCamera = FindFirstObjectByType(); if (newCamera != null) { Undo.RecordObject(target, "Add Camera Preset"); manager.cameraPresets.Add(new CameraManager.CameraPreset(newCamera)); EditorUtility.SetDirty(target); RebuildPresetList(); } else { EditorUtility.DisplayDialog("알림", "Scene에 CinemachineCamera가 없습니다.", "확인"); } } private void DeletePreset(int index) { Undo.RecordObject(target, "Delete Camera Preset"); manager.cameraPresets.RemoveAt(index); EditorUtility.SetDirty(target); RebuildPresetList(); } private void SwapPresets(int a, int b) { Undo.RecordObject(target, "Reorder 카메라 프리셋"); (manager.cameraPresets[a], manager.cameraPresets[b]) = (manager.cameraPresets[b], manager.cameraPresets[a]); EditorUtility.SetDirty(target); RebuildPresetList(); } private void MovePreset(int from, int to) { if (from == to) return; if (from < 0 || from >= manager.cameraPresets.Count) return; if (to < 0 || to >= manager.cameraPresets.Count) return; Undo.RecordObject(target, "Move Camera Preset"); var preset = manager.cameraPresets[from]; manager.cameraPresets.RemoveAt(from); manager.cameraPresets.Insert(to, preset); EditorUtility.SetDirty(target); RebuildPresetList(); } private void DuplicatePreset(int index) { if (index < 0 || index >= manager.cameraPresets.Count) return; Undo.RecordObject(target, "Duplicate Camera Preset"); var source = manager.cameraPresets[index]; var copy = new CameraManager.CameraPreset(source.virtualCamera) { presetName = $"{source.presetName} (복사)", allowMouseControl = source.allowMouseControl }; manager.cameraPresets.Insert(index + 1, copy); EditorUtility.SetDirty(target); RebuildPresetList(); } #endregion #region Play Mode State private void UpdatePlayModeState() { if (presetsContainer == null || manager == null) return; if (!Application.isPlaying) return; for (int i = 0; i < presetsContainer.childCount && i < manager.cameraPresets.Count; i++) { var item = presetsContainer[i]; bool isActive = manager.CurrentPreset == manager.cameraPresets[i]; if (isActive && !item.ClassListContains("preset-item--active")) item.AddToClassList("preset-item--active"); else if (!isActive && item.ClassListContains("preset-item--active")) item.RemoveFromClassList("preset-item--active"); } } #endregion }