584 lines
20 KiB
C#

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<StyleSheet>(CommonUssPath);
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
if (commonUss != null) root.styleSheets.Add(commonUss);
if (uss != null) root.styleSheets.Add(uss);
// UXML template
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(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<DetachFromPanelEvent>(_ => Undo.undoRedoPerformed -= OnUndoRedo);
return root;
}
private void OnUndoRedo()
{
if (manager == null) return;
RebuildPresetList();
}
#region Validation
private void SetupZoomValidation(VisualElement root)
{
var warning = root.Q<HelpBox>("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<HelpBox>("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<PropertyField>("manualRotationTargetField");
var info = root.Q<HelpBox>("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<HelpBox>("blendModeInfo");
var rendererWarning = root.Q<HelpBox>("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<HelpBox>("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<CinemachineBrain>();
if (brain != null) return brain;
}
return FindFirstObjectByType<CinemachineBrain>();
}
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<DragUpdatedEvent>(evt =>
{
if (GetDraggedCameras().Count > 0)
{
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
dropZone?.AddToClassList("preset-drop-zone--active");
evt.StopPropagation();
}
});
target.RegisterCallback<DragLeaveEvent>(_ =>
{
dropZone?.RemoveFromClassList("preset-drop-zone--active");
});
target.RegisterCallback<DragExitedEvent>(_ =>
{
dropZone?.RemoveFromClassList("preset-drop-zone--active");
});
target.RegisterCallback<DragPerformEvent>(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<CinemachineCamera> GetDraggedCameras()
{
var list = new List<CinemachineCamera>();
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<CinemachineCamera>();
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<CinemachineCamera>();
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
}