588 lines
21 KiB
C#

using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Streamingle;
using Newtonsoft.Json;
using NiloToon.NiloToonURP;
using Streamingle.Effects;
#if MAGICACLOTH2
using MagicaCloth2;
#endif
public class AvatarOutfitController : MonoBehaviour, IController
{
#region Classes
[System.Serializable]
public class AvatarData
{
[Header("Avatar Settings")]
public string avatarName = "New Avatar";
[Header("Outfit Settings")]
public OutfitData[] outfits = new OutfitData[0];
[SerializeField] private int currentOutfitIndex = 0;
[System.NonSerialized] public bool isTransforming;
public int CurrentOutfitIndex => currentOutfitIndex;
public OutfitData CurrentOutfit => outfits != null && outfits.Length > currentOutfitIndex ? outfits[currentOutfitIndex] : null;
public AvatarData(string name)
{
avatarName = name;
outfits = new OutfitData[0];
}
public void SetOutfit(int outfitIndex)
{
if (outfits == null || outfitIndex < 0 || outfitIndex >= outfits.Length)
{
Debug.LogWarning($"[AvatarData] 잘못된 의상 인덱스: {outfitIndex}");
return;
}
if (CurrentOutfit != null)
CurrentOutfit.RemoveOutfit();
currentOutfitIndex = outfitIndex;
if (CurrentOutfit != null)
{
CurrentOutfit.ApplyOutfit();
Debug.Log($"[AvatarData] {avatarName} 의상 변경: {CurrentOutfit.outfitName}");
}
}
public override string ToString() => avatarName;
}
[System.Serializable]
public class TransformEffectSettings
{
[Tooltip("변신 연출 사용 여부. 끄면 VFX/SFX/컬러 플래시 없이 즉시 교체만 수행.")]
public bool enabled = true;
[Tooltip("공유 프리셋 (VFX 프리팹, SFX 클립, 컬러 플래시 파라미터). Assets > Create > Streamingle > Transform Effect Preset.")]
public TransformEffectPreset preset;
[Header("Scene References (프리셋과 별도)")]
[Tooltip("VFX 스폰 기준 Transform. 비우면 새 의상의 첫 clothingObject 위치 사용.")]
public Transform vfxAnchor;
[Tooltip("SFX 재생용 AudioSource. 비어있거나 프리셋에 클립이 없으면 SFX 생략.")]
public AudioSource audioSource;
}
[System.Serializable]
public class OutfitData
{
[Header("Outfit Info")]
public string outfitName = "New Outfit";
[Header("Clothing GameObjects")]
[Tooltip("이 의상을 입을 때 표시할 오브젝트들. NiloToonPerCharacterRenderController 가 붙어있으면 renderCharacter=true 로 토글, 없으면 SetActive(true).")]
public GameObject[] clothingObjects = new GameObject[0];
[Tooltip("이 의상을 입을 때 숨길 오브젝트들. NiloToon 있으면 renderCharacter=false, 없으면 SetActive(false).")]
public GameObject[] hideObjects = new GameObject[0];
[Header("Transform Effect (이 의상으로 변경할 때 재생)")]
public TransformEffectSettings transformEffect = new TransformEffectSettings();
#if MAGICACLOTH2
// NiloToon renderCharacter 토글 시 GameObject 가 Active 유지되어 MagicaCloth 가 계속 시뮬되는 문제 방지.
// BuildSimulationCache 로 1회 수집 후 Apply/Remove 에서 cloth.enabled 토글.
[NonSerialized] private MagicaCloth[][] _clothingSimCache;
[NonSerialized] private MagicaCloth[][] _hideSimCache;
[NonSerialized] private bool _simCacheBuilt;
#endif
public void BuildSimulationCache()
{
#if MAGICACLOTH2
if (_simCacheBuilt) return;
_clothingSimCache = CollectCloths(clothingObjects);
_hideSimCache = CollectCloths(hideObjects);
_simCacheBuilt = true;
#endif
}
public void ApplyOutfit()
{
LogToggle("Apply", clothingObjects, true, hideObjects, false);
ApplyArrayState(clothingObjects, true, clothingSide: true);
ApplyArrayState(hideObjects, false, clothingSide: false);
}
public void RemoveOutfit()
{
LogToggle("Remove", clothingObjects, false, hideObjects, true);
ApplyArrayState(clothingObjects, false, clothingSide: true);
ApplyArrayState(hideObjects, true, clothingSide: false);
}
void ApplyArrayState(GameObject[] objs, bool value, bool clothingSide)
{
if (objs == null) return;
for (int i = 0; i < objs.Length; i++)
{
SetRenderOrActive(objs[i], value);
#if MAGICACLOTH2
var cache = clothingSide ? _clothingSimCache : _hideSimCache;
if (cache != null && i < cache.Length)
SetClothsEnabled(cache[i], value);
#endif
}
}
void LogToggle(string op, GameObject[] a, bool av, GameObject[] b, bool bv)
{
var aNames = a == null || a.Length == 0 ? "∅" : string.Join(",", a.Select(o => o != null ? o.name : "null"));
var bNames = b == null || b.Length == 0 ? "∅" : string.Join(",", b.Select(o => o != null ? o.name : "null"));
Debug.Log($"[Outfit:{outfitName}] {op} — clothing→{av}: [{aNames}], hide→{bv}: [{bNames}]");
}
/// <summary>clothingObjects 중 첫 번째 NiloToonPerCharacterRenderController. 컬러 플래시 / VFX 앵커 fallback 용.</summary>
public NiloToonPerCharacterRenderController GetPrimaryRenderController()
{
if (clothingObjects == null) return null;
foreach (var obj in clothingObjects)
{
if (obj == null) continue;
var rc = obj.GetComponent<NiloToonPerCharacterRenderController>();
if (rc != null) return rc;
}
return null;
}
static void SetRenderOrActive(GameObject obj, bool value)
{
if (obj == null) return;
var rc = obj.GetComponent<NiloToonPerCharacterRenderController>();
if (rc != null)
rc.renderCharacter = value;
else
obj.SetActive(value);
}
#if MAGICACLOTH2
static MagicaCloth[][] CollectCloths(GameObject[] objs)
{
if (objs == null) return Array.Empty<MagicaCloth[]>();
var arr = new MagicaCloth[objs.Length][];
for (int i = 0; i < objs.Length; i++)
arr[i] = objs[i] != null
? objs[i].GetComponentsInChildren<MagicaCloth>(true)
: Array.Empty<MagicaCloth>();
return arr;
}
static void SetClothsEnabled(MagicaCloth[] cloths, bool value)
{
if (cloths == null) return;
foreach (var cloth in cloths)
{
if (cloth != null && cloth.enabled != value)
cloth.enabled = value;
}
}
#endif
}
#endregion
#region Events
public delegate void AvatarOutfitChangedEventHandler(AvatarData avatar, OutfitData oldOutfit, OutfitData newOutfit);
public event AvatarOutfitChangedEventHandler OnAvatarOutfitChanged;
/// <summary>UI 등 상태 구독자용 통합 이벤트. 의상 스왑이 실제로 완료된 시점에 발생 (변신 연출 중에는 아직 발생하지 않음).</summary>
public event System.Action OnStateChanged;
#endregion
#region Fields
[SerializeField] public List<AvatarData> avatars = new List<AvatarData>();
[Header("Avatar Control Settings")]
[SerializeField] private bool autoFindAvatars = false;
[SerializeField] private string avatarTag = "Avatar";
[Tooltip("Awake 시 각 아바타의 currentOutfit 을 적용해 초기 표시 상태를 정렬.")]
[SerializeField] private bool syncOutfitStateOnAwake = true;
private AvatarData currentAvatar;
private StreamDeckServerManager streamDeckManager;
#endregion
#region Properties
public AvatarData CurrentAvatar => currentAvatar;
public int CurrentAvatarIndex => avatars.IndexOf(currentAvatar);
public bool IsAnyTransforming => avatars.Any(a => a != null && a.isTransforming);
public bool IsAvatarTransforming(int avatarIndex) =>
avatarIndex >= 0 && avatarIndex < avatars.Count && avatars[avatarIndex] != null && avatars[avatarIndex].isTransforming;
#endregion
#region Unity Messages
private void Awake()
{
InitializeAvatars();
streamDeckManager = FindObjectOfType<StreamDeckServerManager>();
if (streamDeckManager == null)
{
Debug.LogWarning("[AvatarOutfitController] StreamDeckServerManager를 찾을 수 없습니다. 스트림덱 연동이 비활성화됩니다.");
}
}
#endregion
#region Initialization
public void InitializeAvatars()
{
if (avatars == null)
avatars = new List<AvatarData>();
if (autoFindAvatars && avatars.Count == 0)
{
var avatarObjects = GameObject.FindGameObjectsWithTag(avatarTag);
foreach (var avatarObj in avatarObjects)
avatars.Add(new AvatarData(avatarObj.name));
Debug.Log($"[AvatarOutfitController] {avatars.Count}개의 아바타를 자동으로 찾았습니다.");
}
avatars.RemoveAll(avatar => avatar == null);
if (avatars.Count > 0 && currentAvatar == null)
currentAvatar = avatars[0];
BuildAllSimulationCaches();
if (syncOutfitStateOnAwake)
SyncInitialOutfitState();
Debug.Log($"[AvatarOutfitController] 총 {avatars.Count}개의 아바타가 등록되었습니다.");
}
private void BuildAllSimulationCaches()
{
foreach (var avatar in avatars)
{
if (avatar?.outfits == null) continue;
foreach (var outfit in avatar.outfits)
outfit?.BuildSimulationCache();
}
}
/// <summary>모든 아바타의 모든 의상을 먼저 숨긴 뒤, 각 아바타의 currentOutfit 만 표시. 초기 씬 상태 정리용.</summary>
public void SyncInitialOutfitState()
{
// Pass 1: 모든 의상 제거 (아바타 간 교차 hideObjects 간섭 방지)
foreach (var avatar in avatars)
{
if (avatar == null || avatar.outfits == null) continue;
foreach (var outfit in avatar.outfits)
{
if (outfit != null) outfit.RemoveOutfit();
}
}
// Pass 2: 각 아바타의 현재 의상만 적용
foreach (var avatar in avatars)
{
if (avatar == null) continue;
if (avatar.CurrentOutfit != null)
avatar.CurrentOutfit.ApplyOutfit();
}
}
#endregion
#region Public Methods
public void Set(int index)
{
if (avatars.Count > 0)
SetAvatarOutfit(0, index);
else
Debug.LogWarning("[AvatarOutfitController] 설정할 아바타가 없습니다.");
}
public void SetAvatarOutfit(int avatarIndex, int outfitIndex)
{
if (avatarIndex < 0 || avatarIndex >= avatars.Count)
{
Debug.LogWarning($"[AvatarOutfitController] 잘못된 아바타 인덱스: {avatarIndex}");
return;
}
var avatar = avatars[avatarIndex];
if (avatar.outfits == null || outfitIndex < 0 || outfitIndex >= avatar.outfits.Length)
{
Debug.LogWarning($"[AvatarOutfitController] 잘못된 의상 인덱스: {outfitIndex}");
return;
}
if (avatar.CurrentOutfitIndex == outfitIndex) return;
if (avatar.isTransforming)
{
Debug.LogWarning($"[AvatarOutfitController] '{avatar.avatarName}' 이 이미 변신 중입니다.");
return;
}
var oldOutfit = avatar.CurrentOutfit;
var newOutfit = avatar.outfits[outfitIndex];
var effect = newOutfit.transformEffect;
if (effect == null || !effect.enabled)
{
// 즉시 스왑
avatar.SetOutfit(outfitIndex);
OnAvatarOutfitChanged?.Invoke(avatar, oldOutfit, avatar.CurrentOutfit);
NotifyAvatarOutfitChanged(avatar);
return;
}
StartCoroutine(TransformRoutine(avatar, outfitIndex, oldOutfit, newOutfit, effect));
}
public void AddAvatar(string avatarName)
{
var newAvatar = new AvatarData(avatarName);
avatars.Add(newAvatar);
NotifyAvatarOutfitChanged(newAvatar);
Debug.Log($"[AvatarOutfitController] 아바타 추가: {avatarName}");
}
public void RemoveAvatar(int index)
{
if (index < 0 || index >= avatars.Count) return;
var removedAvatar = avatars[index];
avatars.RemoveAt(index);
if (removedAvatar == currentAvatar)
currentAvatar = avatars.Count > 0 ? avatars[0] : null;
Debug.Log($"[AvatarOutfitController] 아바타 제거: {removedAvatar.avatarName}");
}
#endregion
#region Outfit Transformation
private IEnumerator TransformRoutine(AvatarData avatar, int outfitIndex,
OutfitData oldOutfit, OutfitData newOutfit, TransformEffectSettings s)
{
avatar.isTransforming = true;
var preset = s.preset;
if (preset == null)
{
Debug.LogWarning($"[AvatarOutfitController] '{newOutfit.outfitName}' 의 TransformEffectPreset 이 비어있어 즉시 교체합니다.");
avatar.SetOutfit(outfitIndex);
OnAvatarOutfitChanged?.Invoke(avatar, oldOutfit, avatar.CurrentOutfit);
NotifyAvatarOutfitChanged(avatar);
avatar.isTransforming = false;
yield break;
}
var fromRc = oldOutfit != null ? oldOutfit.GetPrimaryRenderController() : null;
var toRc = newOutfit.GetPrimaryRenderController();
SpawnTransformVfx(s, preset, newOutfit);
PlayTransformSfx(s, preset);
// Phase 1: 현재 의상을 flashColor 로 덮음
if (preset.useColorFlash && fromRc != null)
{
if (preset.flashInDuration > 0f)
yield return LerpFlash(fromRc, preset, 0f, preset.flashIntensity, preset.flashInDuration);
else
ApplyFlash(fromRc, preset, preset.flashIntensity);
}
// 새 의상을 flashColor 상태로 프라임해둔 뒤 스왑
if (preset.useColorFlash && toRc != null)
ApplyFlash(toRc, preset, preset.flashIntensity);
avatar.SetOutfit(outfitIndex);
// 이전 NiloToon 은 더 이상 렌더되지 않지만 컬러 상태는 원복 (다음 변경을 위해)
if (preset.useColorFlash && fromRc != null)
ApplyFlash(fromRc, preset, 0f);
OnAvatarOutfitChanged?.Invoke(avatar, oldOutfit, avatar.CurrentOutfit);
NotifyAvatarOutfitChanged(avatar);
// Phase 2: 새 의상이 flashColor 에서 원색으로 복귀
if (preset.useColorFlash && toRc != null)
{
if (preset.flashOutDuration > 0f)
yield return LerpFlash(toRc, preset, preset.flashIntensity, 0f, preset.flashOutDuration);
else
ApplyFlash(toRc, preset, 0f);
}
avatar.isTransforming = false;
Debug.Log($"[AvatarOutfitController] 의상 변신 완료: {avatar.avatarName} → {newOutfit.outfitName}");
}
private void SpawnTransformVfx(TransformEffectSettings s, TransformEffectPreset preset, OutfitData incoming)
{
if (preset.vfxPrefab == null) return;
Transform anchor = s.vfxAnchor;
if (anchor == null)
{
var rc = incoming.GetPrimaryRenderController();
if (rc != null) anchor = rc.transform;
}
if (anchor == null) return;
var vfx = Instantiate(preset.vfxPrefab, anchor.position, anchor.rotation);
if (preset.vfxLifetime > 0f)
Destroy(vfx, preset.vfxLifetime);
}
private void PlayTransformSfx(TransformEffectSettings s, TransformEffectPreset preset)
{
if (s.audioSource == null || preset.sfxClip == null) return;
s.audioSource.PlayOneShot(preset.sfxClip, preset.sfxVolume);
}
private void ApplyFlash(NiloToonPerCharacterRenderController c, TransformEffectPreset preset, float t)
{
c.perCharacterLerpColor = preset.flashColor;
c.perCharacterLerpUsage = t;
}
private IEnumerator LerpFlash(NiloToonPerCharacterRenderController c, TransformEffectPreset preset, float from, float to, float duration)
{
c.perCharacterLerpColor = preset.flashColor;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float k = Mathf.Clamp01(elapsed / duration);
float eased = k * k * (3f - 2f * k);
ApplyFlash(c, preset, Mathf.Lerp(from, to, eased));
yield return null;
}
ApplyFlash(c, preset, to);
}
#endregion
#region StreamDeck Integration
private void NotifyAvatarOutfitChanged(AvatarData avatar)
{
OnStateChanged?.Invoke();
if (streamDeckManager != null)
streamDeckManager.NotifyAvatarOutfitChanged();
}
public AvatarOutfitListData GetAvatarOutfitListData()
{
return new AvatarOutfitListData
{
avatar_count = avatars.Count,
avatars = avatars.Select((a, i) => new AvatarPresetData
{
index = i,
name = a.avatarName,
current_outfit_index = a.CurrentOutfitIndex,
current_outfit_name = a.CurrentOutfit?.outfitName ?? "없음",
outfits = a.outfits?.Select((o, oi) => new OutfitPresetData
{
index = oi,
name = o.outfitName
}).ToArray() ?? new OutfitPresetData[0],
hotkey = "스트림덱 전용"
}).ToArray(),
current_avatar_index = CurrentAvatarIndex
};
}
public AvatarOutfitStateData GetCurrentAvatarOutfitState()
{
if (currentAvatar == null) return null;
return new AvatarOutfitStateData
{
current_avatar_index = CurrentAvatarIndex,
avatar_name = currentAvatar.avatarName,
current_outfit_index = currentAvatar.CurrentOutfitIndex,
current_outfit_name = currentAvatar.CurrentOutfit?.outfitName ?? "없음",
total_avatars = avatars.Count
};
}
public string GetAvatarOutfitListJson()
{
return JsonConvert.SerializeObject(GetAvatarOutfitListData());
}
public string GetAvatarOutfitStateJson()
{
return JsonConvert.SerializeObject(GetCurrentAvatarOutfitState());
}
#endregion
#region Data Classes
[System.Serializable]
public class OutfitPresetData
{
public int index;
public string name;
}
[System.Serializable]
public class AvatarPresetData
{
public int index;
public string name;
public int current_outfit_index;
public string current_outfit_name;
public OutfitPresetData[] outfits;
public string hotkey;
}
[System.Serializable]
public class AvatarOutfitListData
{
public int avatar_count;
public AvatarPresetData[] avatars;
public int current_avatar_index;
}
[System.Serializable]
public class AvatarOutfitStateData
{
public int current_avatar_index;
public string avatar_name;
public int current_outfit_index;
public string current_outfit_name;
public int total_avatars;
}
#endregion
#region IController Implementation
public string GetControllerId() => "avatar_outfit_controller";
public string GetControllerName() => "Avatar Outfit Controller";
public object GetControllerData() => GetAvatarOutfitListData();
public void ExecuteAction(string actionId, object parameters)
{
switch (actionId)
{
case "set_avatar_outfit":
if (parameters is Dictionary<string, object> setParams
&& setParams.ContainsKey("avatar_index") && setParams.ContainsKey("outfit_index"))
{
int avatarIndex = Convert.ToInt32(setParams["avatar_index"]);
int outfitIndex = Convert.ToInt32(setParams["outfit_index"]);
SetAvatarOutfit(avatarIndex, outfitIndex);
}
break;
default:
Debug.LogWarning($"[AvatarOutfitController] 알 수 없는 액션: {actionId}");
break;
}
}
#endregion
}