- AvatarOutfitController: 의상 변경 시 NiloToon renderCharacter 토글 + VFX/SFX/컬러 플래시 연출 - TransformEffectPreset ScriptableObject 로 프리셋 공유 - 아바타별 isTransforming 플래그로 동시 변신 지원 - 의상 리스트 순서 변경 (▲▼) 기능 - Piloto Studio 파티클 번들 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
357 lines
13 KiB
C#
357 lines
13 KiB
C#
using UnityEngine;
|
|
using UnityEditor;
|
|
using UnityEngine.UIElements;
|
|
using UnityEditor.UIElements;
|
|
using System.Collections.Generic;
|
|
|
|
[CustomEditor(typeof(AvatarOutfitController))]
|
|
public class AvatarOutfitControllerEditor : Editor
|
|
{
|
|
private const string UxmlPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/AvatarOutfitControllerEditor.uxml";
|
|
private const string UssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/AvatarOutfitControllerEditor.uss";
|
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
|
|
|
private VisualElement avatarsContainer;
|
|
private Label avatarsTitleLabel;
|
|
private AvatarOutfitController controller;
|
|
|
|
public override VisualElement CreateInspectorGUI()
|
|
{
|
|
controller = (AvatarOutfitController)target;
|
|
var root = new VisualElement();
|
|
|
|
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);
|
|
|
|
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
|
|
if (uxml != null) uxml.CloneTree(root);
|
|
|
|
BuildAvatarsSection(root);
|
|
|
|
Undo.undoRedoPerformed += OnUndoRedo;
|
|
root.RegisterCallback<DetachFromPanelEvent>(_ => Undo.undoRedoPerformed -= OnUndoRedo);
|
|
|
|
return root;
|
|
}
|
|
|
|
private void OnUndoRedo()
|
|
{
|
|
if (controller == null) return;
|
|
serializedObject.Update();
|
|
RebuildAvatarList();
|
|
}
|
|
|
|
#region Avatars List
|
|
|
|
private void BuildAvatarsSection(VisualElement root)
|
|
{
|
|
var section = root.Q("avatarsSection");
|
|
if (section == null) return;
|
|
|
|
var header = new VisualElement();
|
|
header.AddToClassList("list-header");
|
|
|
|
avatarsTitleLabel = new Label($"아바타 ({controller.avatars.Count})");
|
|
avatarsTitleLabel.AddToClassList("list-title");
|
|
header.Add(avatarsTitleLabel);
|
|
|
|
var addBtn = new Button(AddAvatar) { text = "+ 아바타 추가" };
|
|
addBtn.AddToClassList("list-add-btn");
|
|
header.Add(addBtn);
|
|
|
|
section.Add(header);
|
|
|
|
avatarsContainer = new VisualElement();
|
|
section.Add(avatarsContainer);
|
|
|
|
RebuildAvatarList();
|
|
}
|
|
|
|
private void RebuildAvatarList()
|
|
{
|
|
if (avatarsContainer == null || controller == null) return;
|
|
avatarsContainer.Clear();
|
|
|
|
if (avatarsTitleLabel != null)
|
|
avatarsTitleLabel.text = $"아바타 ({controller.avatars.Count})";
|
|
|
|
if (controller.avatars.Count == 0)
|
|
{
|
|
var empty = new Label("아바타가 없습니다. '+ 아바타 추가' 버튼을 눌러 추가하세요.");
|
|
empty.AddToClassList("list-empty");
|
|
avatarsContainer.Add(empty);
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < controller.avatars.Count; i++)
|
|
{
|
|
avatarsContainer.Add(CreateAvatarElement(i));
|
|
}
|
|
|
|
avatarsContainer.Bind(serializedObject);
|
|
}
|
|
|
|
private VisualElement CreateAvatarElement(int avatarIndex)
|
|
{
|
|
var avatar = controller.avatars[avatarIndex];
|
|
|
|
var item = new VisualElement();
|
|
item.AddToClassList("list-item");
|
|
item.AddToClassList("avatar-item");
|
|
|
|
// Header row
|
|
var headerRow = new VisualElement();
|
|
headerRow.AddToClassList("list-item-header");
|
|
|
|
var indexLabel = new Label($"{avatarIndex + 1}");
|
|
indexLabel.AddToClassList("list-index");
|
|
headerRow.Add(indexLabel);
|
|
|
|
var nameField = new TextField();
|
|
nameField.value = avatar.avatarName;
|
|
nameField.AddToClassList("list-name-field");
|
|
int ai = avatarIndex;
|
|
nameField.RegisterValueChangedCallback(evt =>
|
|
{
|
|
Undo.RecordObject(target, "Rename Avatar");
|
|
controller.avatars[ai].avatarName = evt.newValue;
|
|
EditorUtility.SetDirty(target);
|
|
});
|
|
headerRow.Add(nameField);
|
|
|
|
// Reorder buttons
|
|
var upBtn = new Button(() => SwapAvatars(ai, ai - 1)) { text = "\u25B2" };
|
|
upBtn.AddToClassList("list-reorder-btn");
|
|
upBtn.SetEnabled(avatarIndex > 0);
|
|
headerRow.Add(upBtn);
|
|
|
|
var downBtn = new Button(() => SwapAvatars(ai, ai + 1)) { text = "\u25BC" };
|
|
downBtn.AddToClassList("list-reorder-btn");
|
|
downBtn.SetEnabled(avatarIndex < controller.avatars.Count - 1);
|
|
headerRow.Add(downBtn);
|
|
|
|
var deleteBtn = new Button(() => DeleteAvatar(ai)) { text = "X" };
|
|
deleteBtn.AddToClassList("list-delete-btn");
|
|
headerRow.Add(deleteBtn);
|
|
|
|
item.Add(headerRow);
|
|
|
|
// Avatar fields
|
|
var fields = new VisualElement();
|
|
fields.AddToClassList("list-fields");
|
|
|
|
var listProp = serializedObject.FindProperty("avatars");
|
|
var avatarProp = listProp.GetArrayElementAtIndex(avatarIndex);
|
|
|
|
// Outfits sub-list
|
|
int outfitCount = avatar.outfits?.Length ?? 0;
|
|
var outfitsFoldout = new Foldout { text = $"의상 ({outfitCount})", value = true };
|
|
outfitsFoldout.AddToClassList("outfit-foldout");
|
|
|
|
var outfitsContainer = new VisualElement();
|
|
outfitsContainer.AddToClassList("outfits-container");
|
|
|
|
if (avatar.outfits != null && avatar.outfits.Length > 0)
|
|
{
|
|
var outfitsProp = avatarProp.FindPropertyRelative("outfits");
|
|
for (int oi = 0; oi < avatar.outfits.Length; oi++)
|
|
{
|
|
outfitsContainer.Add(CreateOutfitElement(avatarIndex, oi, outfitsProp.GetArrayElementAtIndex(oi)));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var emptyOutfit = new Label("의상이 없습니다.");
|
|
emptyOutfit.AddToClassList("list-empty");
|
|
outfitsContainer.Add(emptyOutfit);
|
|
}
|
|
|
|
var addOutfitBtn = new Button(() => AddOutfit(ai)) { text = "+ 의상 추가" };
|
|
addOutfitBtn.AddToClassList("list-add-btn");
|
|
addOutfitBtn.style.marginTop = 4;
|
|
outfitsContainer.Add(addOutfitBtn);
|
|
|
|
outfitsFoldout.Add(outfitsContainer);
|
|
fields.Add(outfitsFoldout);
|
|
|
|
item.Add(fields);
|
|
return item;
|
|
}
|
|
|
|
private VisualElement CreateOutfitElement(int avatarIndex, int outfitIndex, SerializedProperty outfitProp)
|
|
{
|
|
var avatar = controller.avatars[avatarIndex];
|
|
var outfit = avatar.outfits[outfitIndex];
|
|
bool isCurrentOutfit = avatar.CurrentOutfitIndex == outfitIndex;
|
|
|
|
var outfitItem = new VisualElement();
|
|
outfitItem.AddToClassList("outfit-item");
|
|
if (isCurrentOutfit) outfitItem.AddToClassList("outfit-item--current");
|
|
|
|
// Outfit header
|
|
var outfitHeader = new VisualElement();
|
|
outfitHeader.AddToClassList("outfit-header");
|
|
|
|
var outfitIdx = new Label($"{outfitIndex + 1}");
|
|
outfitIdx.AddToClassList("list-index");
|
|
outfitHeader.Add(outfitIdx);
|
|
|
|
var outfitNameField = new TextField();
|
|
outfitNameField.value = outfit.outfitName;
|
|
outfitNameField.AddToClassList("list-name-field");
|
|
int ai = avatarIndex, oi = outfitIndex;
|
|
outfitNameField.RegisterValueChangedCallback(evt =>
|
|
{
|
|
Undo.RecordObject(target, "Rename Outfit");
|
|
controller.avatars[ai].outfits[oi].outfitName = evt.newValue;
|
|
EditorUtility.SetDirty(target);
|
|
});
|
|
outfitHeader.Add(outfitNameField);
|
|
|
|
if (isCurrentOutfit)
|
|
{
|
|
var currentLabel = new Label("[착용중]");
|
|
currentLabel.AddToClassList("outfit-current-label");
|
|
outfitHeader.Add(currentLabel);
|
|
}
|
|
|
|
var upOutfitBtn = new Button(() => SwapOutfits(ai, oi, oi - 1)) { text = "\u25B2" };
|
|
upOutfitBtn.AddToClassList("list-reorder-btn");
|
|
upOutfitBtn.SetEnabled(outfitIndex > 0);
|
|
outfitHeader.Add(upOutfitBtn);
|
|
|
|
var downOutfitBtn = new Button(() => SwapOutfits(ai, oi, oi + 1)) { text = "\u25BC" };
|
|
downOutfitBtn.AddToClassList("list-reorder-btn");
|
|
downOutfitBtn.SetEnabled(outfitIndex < avatar.outfits.Length - 1);
|
|
outfitHeader.Add(downOutfitBtn);
|
|
|
|
var deleteOutfitBtn = new Button(() => DeleteOutfit(ai, oi)) { text = "X" };
|
|
deleteOutfitBtn.AddToClassList("list-delete-btn");
|
|
outfitHeader.Add(deleteOutfitBtn);
|
|
|
|
outfitItem.Add(outfitHeader);
|
|
|
|
// Outfit fields
|
|
var outfitFields = new VisualElement();
|
|
outfitFields.AddToClassList("outfit-fields");
|
|
|
|
var clothingField = new PropertyField(outfitProp.FindPropertyRelative("clothingObjects"), "의류 오브젝트");
|
|
outfitFields.Add(clothingField);
|
|
|
|
var hideField = new PropertyField(outfitProp.FindPropertyRelative("hideObjects"), "숨길 오브젝트");
|
|
outfitFields.Add(hideField);
|
|
|
|
// 변신 효과 Foldout (이 의상으로 변경해 들어올 때 재생)
|
|
var fxFoldout = new Foldout { text = "변신 효과", value = false };
|
|
fxFoldout.AddToClassList("transform-fx-foldout");
|
|
var fxField = new PropertyField(outfitProp.FindPropertyRelative("transformEffect"), "");
|
|
fxFoldout.Add(fxField);
|
|
outfitFields.Add(fxFoldout);
|
|
|
|
if (Application.isPlaying)
|
|
{
|
|
var testBtn = new Button(() => controller.SetAvatarOutfit(ai, oi))
|
|
{
|
|
text = isCurrentOutfit ? "현재 의상" : "▶ 이 의상으로 변경"
|
|
};
|
|
testBtn.AddToClassList("list-add-btn");
|
|
testBtn.style.marginTop = 2;
|
|
testBtn.SetEnabled(!isCurrentOutfit && !controller.IsAvatarTransforming(ai));
|
|
outfitFields.Add(testBtn);
|
|
}
|
|
|
|
outfitItem.Add(outfitFields);
|
|
return outfitItem;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Avatar Actions
|
|
|
|
private void AddAvatar()
|
|
{
|
|
Undo.RecordObject(target, "Add Avatar");
|
|
controller.avatars.Add(new AvatarOutfitController.AvatarData("새 아바타"));
|
|
EditorUtility.SetDirty(target);
|
|
serializedObject.Update();
|
|
RebuildAvatarList();
|
|
}
|
|
|
|
private void DeleteAvatar(int index)
|
|
{
|
|
Undo.RecordObject(target, "Delete Avatar");
|
|
controller.avatars.RemoveAt(index);
|
|
EditorUtility.SetDirty(target);
|
|
serializedObject.Update();
|
|
RebuildAvatarList();
|
|
}
|
|
|
|
private void SwapAvatars(int a, int b)
|
|
{
|
|
Undo.RecordObject(target, "Reorder Avatars");
|
|
(controller.avatars[a], controller.avatars[b]) = (controller.avatars[b], controller.avatars[a]);
|
|
EditorUtility.SetDirty(target);
|
|
serializedObject.Update();
|
|
RebuildAvatarList();
|
|
}
|
|
|
|
private void AddOutfit(int avatarIndex)
|
|
{
|
|
Undo.RecordObject(target, "Add Outfit");
|
|
var avatar = controller.avatars[avatarIndex];
|
|
var outfits = avatar.outfits != null
|
|
? new List<AvatarOutfitController.OutfitData>(avatar.outfits)
|
|
: new List<AvatarOutfitController.OutfitData>();
|
|
outfits.Add(new AvatarOutfitController.OutfitData { outfitName = "새 의상" });
|
|
avatar.outfits = outfits.ToArray();
|
|
EditorUtility.SetDirty(target);
|
|
serializedObject.Update();
|
|
RebuildAvatarList();
|
|
}
|
|
|
|
private void SwapOutfits(int avatarIndex, int a, int b)
|
|
{
|
|
var avatar = controller.avatars[avatarIndex];
|
|
if (avatar.outfits == null) return;
|
|
if (a < 0 || a >= avatar.outfits.Length || b < 0 || b >= avatar.outfits.Length) return;
|
|
if (a == b) return;
|
|
|
|
// SerializedProperty 로만 처리해 직렬화 상태와 런타임 상태 간 동기 문제를 방지
|
|
serializedObject.Update();
|
|
var listProp = serializedObject.FindProperty("avatars");
|
|
var avatarProp = listProp.GetArrayElementAtIndex(avatarIndex);
|
|
var outfitsProp = avatarProp.FindPropertyRelative("outfits");
|
|
|
|
// MoveArrayElement 는 인접 요소 이동이면 스왑과 동일 효과
|
|
outfitsProp.MoveArrayElement(a, b);
|
|
|
|
var currentIdxProp = avatarProp.FindPropertyRelative("currentOutfitIndex");
|
|
if (currentIdxProp != null)
|
|
{
|
|
int cur = currentIdxProp.intValue;
|
|
if (cur == a) currentIdxProp.intValue = b;
|
|
else if (cur == b) currentIdxProp.intValue = a;
|
|
}
|
|
|
|
serializedObject.ApplyModifiedProperties();
|
|
EditorUtility.SetDirty(target);
|
|
RebuildAvatarList();
|
|
}
|
|
|
|
private void DeleteOutfit(int avatarIndex, int outfitIndex)
|
|
{
|
|
Undo.RecordObject(target, "Delete Outfit");
|
|
var outfits = new List<AvatarOutfitController.OutfitData>(controller.avatars[avatarIndex].outfits);
|
|
outfits.RemoveAt(outfitIndex);
|
|
controller.avatars[avatarIndex].outfits = outfits.ToArray();
|
|
EditorUtility.SetDirty(target);
|
|
serializedObject.Update();
|
|
RebuildAvatarList();
|
|
}
|
|
|
|
#endregion
|
|
|
|
}
|