[Abnormality] 이상 상태 시스템 구현

- 이상 상태 데이터 (버프/디버프) ScriptableObject 정의
- 런타임임 활성 이상 상태 관리 (ActiveAbnormality)
- 캐릭터터별 AbnormalityManager 컴포넌트로 이상 상태 적용/제거
- 스킬 효과(AbnormalityEffect)로 스킬에 이상 상태 연동
- UI 슬롯 및 목록 표시 구현 (버프/디버프 구분)
- 테스트 코드 및 씬 설정 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 01:35:02 +09:00
parent 8add066c3c
commit ec99e302ed
35 changed files with 2586 additions and 38 deletions

View File

@@ -0,0 +1,279 @@
using System.Collections.Generic;
using UnityEngine;
using Colosseum.Abnormalities;
namespace Colosseum.UI
{
/// <summary>
/// 이상 상태 목록 UI 관리자
/// 버프/디버프 목록을 표시하고 관리합니다.
/// </summary>
public class AbnormalityListUI : MonoBehaviour
{
[Header("Containers")]
[Tooltip("버프 컨테이너")]
[SerializeField] private Transform buffContainer;
[Tooltip("디버프 컨테이너")]
[SerializeField] private Transform debuffContainer;
[Header("Prefab")]
[Tooltip("이상 상태 슬롯 프리팹")]
[SerializeField] private AbnormalitySlotUI slotPrefab;
[Header("Settings")]
[Tooltip("최대 표시 개수")]
[SerializeField] private int maxSlots = 10;
[Tooltip("자동으로 플레이어 추적")]
[SerializeField] private bool autoFindPlayer = true;
// 추적 중인 AbnormalityManager
private AbnormalityManager targetManager;
// 생성된 슬롯 풀
private readonly List<AbnormalitySlotUI> slotPool = new List<AbnormalitySlotUI>();
// 현재 활성화된 슬롯 목록
private readonly List<AbnormalitySlotUI> activeSlots = new List<AbnormalitySlotUI>();
// 이전 프레임의 효과 수 (변경 감지용)
private int lastAbnormalityCount = -1;
private void Start()
{
if (autoFindPlayer)
{
// 로컬 플레이어 찾기
FindLocalPlayer();
}
// 슬롯 풀 초기화
InitializeSlotPool();
}
private void OnDestroy()
{
// 이벤트 구독 해제
if (targetManager != null)
{
targetManager.OnAbnormalityAdded -= OnAbnormalityAdded;
targetManager.OnAbnormalityRemoved -= OnAbnormalityRemoved;
targetManager.OnAbnormalitiesChanged -= OnAbnormalitiesChanged;
}
}
private void Update()
{
// 주기적으로 UI 갱신 (성능 최적화를 위해 매 프레임이 아닌 일정 간격으로)
if (Time.frameCount % 10 == 0) // 10프레임마다 한 번
{
RefreshUI();
}
}
/// <summary>
/// 로컬 플레이어 찾기
/// </summary>
private void FindLocalPlayer()
{
var playerObjects = FindObjectsByType<AbnormalityManager>(FindObjectsSortMode.None);
foreach (var manager in playerObjects)
{
// 네트워크 오브젝트인 경우 로컬 플레이어 확인
if (manager.TryGetComponent<Unity.Netcode.NetworkObject>(out var netObj) && netObj.IsOwner)
{
SetTarget(manager);
return;
}
}
// 네트워크 오브젝트가 없거나 로컬 플레이어를 찾지 못한 경우
// 첫 번째 플레이어 사용 (싱글플레이어용)
if (playerObjects.Length > 0)
{
SetTarget(playerObjects[0]);
}
}
/// <summary>
/// 추적 대상 설정
/// </summary>
/// <param name="manager">추적할 AbnormalityManager</param>
public void SetTarget(AbnormalityManager manager)
{
// 기존 구독 해제
if (targetManager != null)
{
targetManager.OnAbnormalityAdded -= OnAbnormalityAdded;
targetManager.OnAbnormalityRemoved -= OnAbnormalityRemoved;
targetManager.OnAbnormalitiesChanged -= OnAbnormalitiesChanged;
}
targetManager = manager;
// 새로운 대상 구독
if (targetManager != null)
{
targetManager.OnAbnormalityAdded += OnAbnormalityAdded;
targetManager.OnAbnormalityRemoved += OnAbnormalityRemoved;
targetManager.OnAbnormalitiesChanged += OnAbnormalitiesChanged;
}
// 즉시 UI 갱신
ForceRefreshUI();
}
/// <summary>
/// 슬롯 풀 초기화
/// </summary>
private void InitializeSlotPool()
{
if (slotPrefab == null)
{
Debug.LogWarning("[AbnormalityListUI] Slot prefab is not assigned");
return;
}
// 필요한 만큼 슬롯 미리 생성
for (int i = 0; i < maxSlots; i++)
{
var slot = CreateSlot();
slot.gameObject.SetActive(false);
slotPool.Add(slot);
}
}
/// <summary>
/// 새 슬롯 생성
/// </summary>
private AbnormalitySlotUI CreateSlot()
{
var go = Instantiate(slotPrefab.gameObject, transform);
return go.GetComponent<AbnormalitySlotUI>();
}
/// <summary>
/// 슬롯 가져오기 (풀에서 또는 새로 생성)
/// </summary>
private AbnormalitySlotUI GetSlot()
{
// 풀에서 비활성화된 슬롯 찾기
foreach (var slot in slotPool)
{
if (!slot.gameObject.activeSelf)
{
return slot;
}
}
// 풀에 없으면 새로 생성
if (slotPool.Count < maxSlots)
{
var newSlot = CreateSlot();
slotPool.Add(newSlot);
return newSlot;
}
return null;
}
/// <summary>
/// 슬롯 반환 (비활성화)
/// </summary>
private void ReturnSlot(AbnormalitySlotUI slot)
{
slot.gameObject.SetActive(false);
activeSlots.Remove(slot);
}
/// <summary>
/// 이상 상태 추가 시 호출
/// </summary>
private void OnAbnormalityAdded(ActiveAbnormality abnormality)
{
ForceRefreshUI();
}
/// <summary>
/// 이상 상태 제거 시 호출
/// </summary>
private void OnAbnormalityRemoved(ActiveAbnormality abnormality)
{
ForceRefreshUI();
}
/// <summary>
/// 이상 상태 변경 시 호출
/// </summary>
private void OnAbnormalitiesChanged()
{
ForceRefreshUI();
}
/// <summary>
/// UI 강제 갱신
/// </summary>
public void ForceRefreshUI()
{
if (targetManager == null) return;
// 모든 슬롯 반환
foreach (var slot in activeSlots.ToArray())
{
ReturnSlot(slot);
}
activeSlots.Clear();
// 활성화된 이상 상태 표시
var abnormalities = targetManager.ActiveAbnormalities;
foreach (var abnormality in abnormalities)
{
var slot = GetSlot();
if (slot == null) continue;
// 버프/디버프에 따라 적절한 컨테이너에 배치
Transform container = abnormality.Data.isDebuff ? debuffContainer : buffContainer;
if (container == null) container = transform;
slot.transform.SetParent(container, false);
slot.Initialize(abnormality);
slot.gameObject.SetActive(true);
activeSlots.Add(slot);
}
lastAbnormalityCount = abnormalities.Count;
}
/// <summary>
/// UI 주기적 갱신 (변경 감지 시에만)
/// </summary>
private void RefreshUI()
{
if (targetManager == null) return;
int currentCount = targetManager.ActiveAbnormalities.Count;
// 이상 상태 수가 변경되었으면 갱신
if (currentCount != lastAbnormalityCount)
{
ForceRefreshUI();
}
}
/// <summary>
/// 모든 슬롯 숨기기
/// </summary>
public void HideAll()
{
foreach (var slot in activeSlots.ToArray())
{
ReturnSlot(slot);
}
activeSlots.Clear();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 15447f4a4d271354fb52bbdf1a526c6e

View File

@@ -0,0 +1,182 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Colosseum.Abnormalities;
namespace Colosseum.UI
{
/// <summary>
/// 개별 이상 상태 UI 슬롯
/// 버프/디버프 아이콘, 지속 시간 등을 표시합니다.
/// </summary>
public class AbnormalitySlotUI : MonoBehaviour
{
[Header("UI References")]
[Tooltip("이상 상태 아이콘")]
[SerializeField] private Image iconImage;
[Tooltip("지속 시간 채우기 이미지 (시계 방향)")]
[SerializeField] private Image durationFill;
[Tooltip("남은 시간 텍스트")]
[SerializeField] private TMP_Text durationText;
[Tooltip("효과 이름 텍스트")]
[SerializeField] private TMP_Text effectNameText;
[Tooltip("배경 이미지 (버프/디버프 구분용)")]
[SerializeField] private Image backgroundImage;
[Header("Colors")]
[Tooltip("버프 배경 색상")]
[SerializeField] private Color buffColor = new Color(0.2f, 0.6f, 0.2f, 0.8f);
[Tooltip("디버프 배경 색상")]
[SerializeField] private Color debuffColor = new Color(0.6f, 0.2f, 0.2f, 0.8f);
private ActiveAbnormality trackedAbnormality;
/// <summary>
/// 추적 중인 활성 이상 상태
/// </summary>
public ActiveAbnormality TrackedAbnormality => trackedAbnormality;
/// <summary>
/// UI 초기화
/// </summary>
/// <param name="abnormality">표시할 활성 이상 상태</param>
public void Initialize(ActiveAbnormality abnormality)
{
trackedAbnormality = abnormality;
if (abnormality?.Data == null)
{
Debug.LogWarning("[AbnormalitySlotUI] Initialize called with null abnormality or data");
return;
}
// 아이콘 설정
if (iconImage != null)
{
iconImage.sprite = abnormality.Data.icon;
iconImage.enabled = abnormality.Data.icon != null;
}
// 이름 설정
if (effectNameText != null)
{
effectNameText.text = abnormality.Data.abnormalityName;
}
// 배경 색상 설정 (버프/디버프 구분)
if (backgroundImage != null)
{
backgroundImage.color = abnormality.Data.isDebuff ? debuffColor : buffColor;
}
// 초기 상태 업데이트
UpdateDisplay(abnormality.RemainingDuration, abnormality.Data.duration);
}
/// <summary>
/// 화면 표시 업데이트
/// </summary>
/// <param name="remainingDuration">남은 시간</param>
/// <param name="totalDuration">전체 시간</param>
public void UpdateDisplay(float remainingDuration, float totalDuration)
{
// 지속 시간 채우기 업데이트
if (durationFill != null)
{
if (totalDuration > 0f)
{
float fillAmount = Mathf.Clamp01(remainingDuration / totalDuration);
durationFill.fillAmount = fillAmount;
durationFill.enabled = true;
}
else
{
// 영구 효과
durationFill.fillAmount = 1f;
}
}
// 남은 시간 텍스트 업데이트
if (durationText != null)
{
if (totalDuration > 0f)
{
if (remainingDuration >= 60f)
{
durationText.text = $"{remainingDuration / 60f:F0}m";
}
else if (remainingDuration >= 1f)
{
durationText.text = $"{remainingDuration:F0}s";
}
else
{
durationText.text = $"{remainingDuration:F1}s";
}
}
else
{
// 영구 효과
durationText.text = "∞";
}
}
}
/// <summary>
/// 프레임마다 호출하여 추적 중인 효과 업데이트
/// </summary>
private void Update()
{
if (trackedAbnormality == null)
{
gameObject.SetActive(false);
return;
}
UpdateDisplay(trackedAbnormality.RemainingDuration, trackedAbnormality.Data.duration);
}
/// <summary>
/// 툴팁 표시용 정보 반환
/// </summary>
public string GetTooltipText()
{
if (trackedAbnormality?.Data == null) return string.Empty;
var data = trackedAbnormality.Data;
string tooltip = $"<b>{data.abnormalityName}</b>\n";
if (!data.IsPermanent)
{
tooltip += $"지속 시간: {trackedAbnormality.RemainingDuration:F1}초\n";
}
else
{
tooltip += "영구 효과\n";
}
if (data.HasPeriodicEffect)
{
tooltip += $"주기적 효과: {data.periodicValue:+0}/ {data.periodicInterval}초\n";
}
if (data.HasControlEffect)
{
tooltip += $"제어 효과: {data.controlType}\n";
}
foreach (var mod in data.statModifiers)
{
string sign = mod.value >= 0 ? "+" : "";
tooltip += $"{mod.statType}: {sign}{mod.value}\n";
}
return tooltip;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 287a45a81e69cbf48845f88759cf7eb4

View File

@@ -0,0 +1,116 @@
using UnityEngine;
using Colosseum.Abnormalities;
namespace Colosseum.UI
{
/// <summary>
/// 이상 상태 시스템 테스트 스크립트
/// Q 키: 버프 적용, E 키: 디버프 적용
/// 로그를 통해 OnEffect 이벤트 호출 및 이상 상태 적용 과정을 추적합니다.
/// </summary>
public class AbnormalitySystemTest : MonoBehaviour
{
private AbnormalityManager abnormalityManager;
private AbnormalityData testBuff;
private AbnormalityData testDebuff;
private float testTimer;
void Start()
{
// 플레이어에서 AbnormalityManager 찾기
abnormalityManager = GetComponent<AbnormalityManager>();
if (abnormalityManager == null)
{
Debug.LogError("[AbnormalitySystemTest] AbnormalityManager not found on player!");
return;
}
// 테스트용 이상 상태 데이터 생성 (에셋 생성)
testBuff = ScriptableObject.CreateInstance<AbnormalityData>();
testBuff.abnormalityName = "Test Buff";
testBuff.duration = 5f;
testBuff.isDebuff = false;
testDebuff = ScriptableObject.CreateInstance<AbnormalityData>();
testDebuff.abnormalityName = "Test Debuff";
testDebuff.duration = 5f;
testDebuff.isDebuff = true;
// 이벤트 구독
abnormalityManager.OnAbnormalityAdded += OnAbnormalityAdded;
abnormalityManager.OnAbnormalityRemoved += OnAbnormalityRemoved;
Debug.Log("=== Abnormality System Test Started ===");
Debug.Log("Press Q to apply buff, Press E to apply debuff");
Debug.Log($"Initial Active Abnormalities Count: {abnormalityManager.ActiveAbnormalities.Count}");
}
void Update()
{
testTimer += Time.deltaTime;
// Q 키로 버프 적용 (3초마다 1회만)
if (Input.GetKeyDown(KeyCode.Q) && testTimer >= 3f)
{
testTimer = 0f;
ApplyTestBuff();
}
// E 키로 디버프 적용 (3초마다 1회만)
if (Input.GetKeyDown(KeyCode.E) && testTimer >= 3f)
{
testTimer = 0f;
ApplyTestDebuff();
}
}
private void ApplyTestBuff()
{
if (testBuff == null || abnormalityManager == null)
{
Debug.LogWarning("[AbnormalitySystemTest] Cannot apply buff - data or manager is null");
return;
}
Debug.Log($"[AbnormalitySystemTest] >>> Applying BUFF: {testBuff.abnormalityName} to {gameObject.name}");
abnormalityManager.ApplyAbnormality(testBuff, gameObject);
}
private void ApplyTestDebuff()
{
if (testDebuff == null || abnormalityManager == null)
{
Debug.LogWarning("[AbnormalitySystemTest] Cannot apply debuff - data or manager is null");
return;
}
Debug.Log($"[AbnormalitySystemTest] >>> Applying DEBUFF: {testDebuff.abnormalityName} to {gameObject.name}");
abnormalityManager.ApplyAbnormality(testDebuff, gameObject);
}
private void OnAbnormalityAdded(ActiveAbnormality abnormality)
{
Debug.Log($"[AbnormalitySystemTest] <<< ABNORMALITY ADDED: {abnormality.Data.abnormalityName} | isDebuff: {abnormality.Data.isDebuff} | Duration: {abnormality.Data.duration}s | Remaining: {abnormality.RemainingDuration:F1}s");
}
private void OnAbnormalityRemoved(ActiveAbnormality abnormality)
{
Debug.Log($"[AbnormalitySystemTest] <<< ABNORMALITY REMOVED: {abnormality.Data.abnormalityName}");
}
void OnDestroy()
{
// 이벤트 구독 해제
if (abnormalityManager != null)
{
abnormalityManager.OnAbnormalityAdded -= OnAbnormalityAdded;
abnormalityManager.OnAbnormalityRemoved -= OnAbnormalityRemoved;
}
// 정리
if (testBuff != null) Destroy(testBuff);
if (testDebuff != null) Destroy(testDebuff);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a1b2c3d4e5f6a7b8c9a0d1e5e3