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; 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(); public void ApplyOutfit() { LogToggle("Apply", clothingObjects, true, hideObjects, false); foreach (var obj in clothingObjects) SetRenderOrActive(obj, true); foreach (var obj in hideObjects) SetRenderOrActive(obj, false); } public void RemoveOutfit() { LogToggle("Remove", clothingObjects, false, hideObjects, true); foreach (var obj in clothingObjects) SetRenderOrActive(obj, false); foreach (var obj in hideObjects) SetRenderOrActive(obj, true); } 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}]"); } /// clothingObjects 중 첫 번째 NiloToonPerCharacterRenderController. 컬러 플래시 / VFX 앵커 fallback 용. public NiloToonPerCharacterRenderController GetPrimaryRenderController() { if (clothingObjects == null) return null; foreach (var obj in clothingObjects) { if (obj == null) continue; var rc = obj.GetComponent(); if (rc != null) return rc; } return null; } static void SetRenderOrActive(GameObject obj, bool value) { if (obj == null) return; var rc = obj.GetComponent(); if (rc != null) rc.renderCharacter = value; else obj.SetActive(value); } } #endregion #region Events public delegate void AvatarOutfitChangedEventHandler(AvatarData avatar, OutfitData oldOutfit, OutfitData newOutfit); public event AvatarOutfitChangedEventHandler OnAvatarOutfitChanged; #endregion #region Fields [SerializeField] public List avatars = new List(); [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(); if (streamDeckManager == null) { Debug.LogWarning("[AvatarOutfitController] StreamDeckServerManager를 찾을 수 없습니다. 스트림덱 연동이 비활성화됩니다."); } } #endregion #region Initialization public void InitializeAvatars() { if (avatars == null) avatars = new List(); 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]; if (syncOutfitStateOnAwake) SyncInitialOutfitState(); Debug.Log($"[AvatarOutfitController] 총 {avatars.Count}개의 아바타가 등록되었습니다."); } /// 모든 아바타의 모든 의상을 먼저 숨긴 뒤, 각 아바타의 currentOutfit 만 표시. 초기 씬 상태 정리용. 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) { 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 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 }