chore: Assets 디렉토리 구조 정리 및 네이밍 컨벤션 적용

- Assets/_Game/ 하위로 게임 에셋 통합
- External/ 패키지 벤더별 분류 (Synty, Animations, UI)
- 에셋 네이밍 컨벤션 확립 및 적용
  (Data_Skill_, Data_SkillEffect_, Prefab_, Anim_, Model_, BT_ 등)
- pre-commit hook으로 네이밍 컨벤션 자동 검사 추가
- RESTRUCTURE_CHECKLIST.md 작성

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 19:08:27 +09:00
parent 309bf5f48b
commit c265f980db
17251 changed files with 2630777 additions and 206 deletions

View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using Colosseum.Stats;
namespace Colosseum.Abnormalities
{
/// <summary>
/// 제어 효과 타입
/// </summary>
public enum ControlType
{
None, // 제어 효과 없음
Stun, // 기절 (이동, 스킬 사용 불가)
Silence, // 침묵 (스킬 사용 불가)
Slow // 둔화 (이동 속도 감소)
}
/// <summary>
/// 스탯 수정자 엔트리
/// </summary>
[Serializable]
public class AbnormalityStatModifier
{
[Tooltip("수정할 스탯 타입")]
public StatType statType;
[Tooltip("수정값")]
public float value;
[Tooltip("수정 타입 (Flat: 고정값, PercentAdd: 퍼센트 합산, PercentMult: 퍼센트 곱셈)")]
public StatModType modType;
public AbnormalityStatModifier() { }
public AbnormalityStatModifier(StatType statType, float value, StatModType modType)
{
this.statType = statType;
this.value = value;
this.modType = modType;
}
}
/// <summary>
/// 이상 상태 정의 ScriptableObject
/// 버프/디버프의 데이터를 정의합니다.
/// </summary>
[CreateAssetMenu(fileName = "AbnormalityData", menuName = "Colosseum/Abnormalities/Abnormality")]
public class AbnormalityData : ScriptableObject
{
[Header("기본 정보")]
[Tooltip("이상 상태 이름")]
public string abnormalityName = "Abnormality";
[Tooltip("아이콘")]
public Sprite icon;
[Tooltip("지속 시간 (초, 0 이하면 영구)")]
public float duration = 5f;
[Tooltip("효과 레벨 (중복 처리용, 높으면 우선)")]
public int level = 1;
[Tooltip("디버프 여부")]
public bool isDebuff = false;
[Header("스탯 수정자")]
[Tooltip("스탯에 적용할 수정자 목록")]
public List<AbnormalityStatModifier> statModifiers = new List<AbnormalityStatModifier>();
[Header("주기적 효과 (DoT/HoT)")]
[Tooltip("주기적 효과 간격 (초, 0이면 비활성)")]
public float periodicInterval = 0f;
[Tooltip("주기적 효과값 (양수=힐, 음수=데미지)")]
public float periodicValue = 0f;
[Header("제어 효과 (CC)")]
[Tooltip("제어 효과 타입")]
public ControlType controlType = ControlType.None;
[Tooltip("둔화 배율 (Slow일 때, 0.5 = 50% 감소)")]
[Range(0f, 1f)]
public float slowMultiplier = 0.5f;
/// <summary>
/// 영구 효과인지 확인
/// </summary>
public bool IsPermanent => duration <= 0f;
/// <summary>
/// 주기적 효과가 있는지 확인
/// </summary>
public bool HasPeriodicEffect => periodicInterval > 0f && periodicValue != 0f;
/// <summary>
/// 제어 효과가 있는지 확인
/// </summary>
public bool HasControlEffect => controlType != ControlType.None;
}
}

View File

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

View File

@@ -0,0 +1,494 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using Unity.Netcode;
using Colosseum.Stats;
using Colosseum.Player;
namespace Colosseum.Abnormalities
{
/// <summary>
/// 캐릭터에 부착되어 이상 상태를 관리하는 컴포넌트
/// 버프/디버프의 적용, 제거, 주기적 효과를 처리합니다.
/// </summary>
public class AbnormalityManager : NetworkBehaviour
{
[Header("References")]
[Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")]
[SerializeField] private CharacterStats characterStats;
[Tooltip("PlayerNetworkController 컴포넌트 (HP/MP 관리용)")]
[SerializeField] private PlayerNetworkController networkController;
// 활성화된 이상 상태 목록
private readonly List<ActiveAbnormality> activeAbnormalities = new List<ActiveAbnormality>();
// 제어 효과 상태
private int stunCount;
private int silenceCount;
private float slowMultiplier = 1f;
// 네트워크 동기화용 데이터
private NetworkList<AbnormalitySyncData> syncedAbnormalities;
/// <summary>
/// 기절 상태 여부
/// </summary>
public bool IsStunned => stunCount > 0;
/// <summary>
/// 침묵 상태 여부
/// </summary>
public bool IsSilenced => silenceCount > 0;
/// <summary>
/// 이동 속도 배율 (1.0 = 기본, 0.5 = 50% 감소)
/// </summary>
public float MoveSpeedMultiplier => slowMultiplier;
/// <summary>
/// 행동 가능 여부 (기절이 아닐 때)
/// </summary>
public bool CanAct => !IsStunned;
/// <summary>
/// 스킬 사용 가능 여부
/// </summary>
public bool CanUseSkills => !IsStunned && !IsSilenced;
/// <summary>
/// 활성화된 이상 상태 목록 (읽기 전용)
/// </summary>
public IReadOnlyList<ActiveAbnormality> ActiveAbnormalities => activeAbnormalities;
// 이벤트
public event Action<ActiveAbnormality> OnAbnormalityAdded;
public event Action<ActiveAbnormality> OnAbnormalityRemoved;
public event Action OnAbnormalitiesChanged;
/// <summary>
/// 네트워크 동기화용 이상 상태 데이터 구조체
/// </summary>
private struct AbnormalitySyncData : INetworkSerializable, IEquatable<AbnormalitySyncData>
{
public int AbnormalityId;
public float RemainingDuration;
public ulong SourceClientId;
public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
{
serializer.SerializeValue(ref AbnormalityId);
serializer.SerializeValue(ref RemainingDuration);
serializer.SerializeValue(ref SourceClientId);
}
public bool Equals(AbnormalitySyncData other)
{
return AbnormalityId == other.AbnormalityId;
}
}
private void Awake()
{
if (characterStats == null)
characterStats = GetComponent<CharacterStats>();
if (networkController == null)
networkController = GetComponent<PlayerNetworkController>();
syncedAbnormalities = new NetworkList<AbnormalitySyncData>();
}
public override void OnNetworkSpawn()
{
syncedAbnormalities.OnListChanged += OnSyncedAbnormalitiesChanged;
}
public override void OnNetworkDespawn()
{
syncedAbnormalities.OnListChanged -= OnSyncedAbnormalitiesChanged;
}
private void Update()
{
if (!IsServer) return;
UpdateAbnormalities(Time.deltaTime);
}
/// <summary>
/// 이상 상태 적용
/// </summary>
/// <param name="data">적용할 이상 상태 데이터</param>
/// <param name="source">효과 시전자</param>
public void ApplyAbnormality(AbnormalityData data, GameObject source)
{
if (data == null)
{
Debug.LogWarning("[Abnormality] ApplyAbnormality called with null data");
return;
}
if (IsServer)
{
ApplyAbnormalityInternal(data, source);
}
else
{
var sourceNetId = source != null && source.TryGetComponent<NetworkObject>(out var netObj) ? netObj.NetworkObjectId : 0UL;
ApplyAbnormalityServerRpc(data.GetInstanceID(), sourceNetId);
}
}
[Rpc(SendTo.Server)]
private void ApplyAbnormalityServerRpc(int dataId, ulong sourceNetworkId)
{
var data = FindAbnormalityDataById(dataId);
if (data == null)
{
Debug.LogWarning($"[Abnormality] Could not find data with ID: {dataId}");
return;
}
GameObject source = null;
if (sourceNetworkId != 0UL && NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(sourceNetworkId, out var netObj))
{
source = netObj.gameObject;
}
ApplyAbnormalityInternal(data, source);
}
private void ApplyAbnormalityInternal(AbnormalityData data, GameObject source)
{
var existing = FindExistingAbnormality(data);
if (existing != null)
{
if (existing.Data == data)
{
existing.RefreshDuration();
UpdateSyncedAbnormalityDuration(existing);
Debug.Log($"[Abnormality] Refreshed {data.abnormalityName} on {gameObject.name}");
return;
}
if (data.level > existing.Data.level)
{
RemoveAbnormalityInternal(existing);
}
else
{
Debug.Log($"[Abnormality] Ignored {data.abnormalityName} (level {data.level}) - existing level {existing.Data.level} is higher or equal");
return;
}
}
var newAbnormality = new ActiveAbnormality(data, source);
activeAbnormalities.Add(newAbnormality);
ApplyStatModifiers(newAbnormality);
ApplyControlEffect(data);
SyncAbnormalityAdd(newAbnormality, source);
OnAbnormalityAdded?.Invoke(newAbnormality);
OnAbnormalitiesChanged?.Invoke();
Debug.Log($"[Abnormality] Applied {data.abnormalityName} (level {data.level}) to {gameObject.name} for {data.duration}s");
}
/// <summary>
/// 이상 상태 제거
/// </summary>
/// <param name="data">제거할 이상 상태 데이터</param>
public void RemoveAbnormality(AbnormalityData data)
{
if (data == null) return;
if (IsServer)
{
var abnormality = FindExistingAbnormality(data);
if (abnormality != null)
{
RemoveAbnormalityInternal(abnormality);
}
}
else
{
RemoveAbnormalityServerRpc(data.GetInstanceID());
}
}
[Rpc(SendTo.Server)]
private void RemoveAbnormalityServerRpc(int dataId)
{
var abnormality = activeAbnormalities.Find(a => a.Data.GetInstanceID() == dataId);
if (abnormality != null)
{
RemoveAbnormalityInternal(abnormality);
}
}
private void RemoveAbnormalityInternal(ActiveAbnormality abnormality)
{
RemoveStatModifiers(abnormality);
RemoveControlEffect(abnormality.Data);
SyncAbnormalityRemove(abnormality);
activeAbnormalities.Remove(abnormality);
OnAbnormalityRemoved?.Invoke(abnormality);
OnAbnormalitiesChanged?.Invoke();
Debug.Log($"[Abnormality] Removed {abnormality.Data.abnormalityName} from {gameObject.name}");
}
/// <summary>
/// 모든 이상 상태 제거
/// </summary>
public void RemoveAllAbnormalities()
{
if (!IsServer)
{
RemoveAllAbnormalitiesServerRpc();
return;
}
while (activeAbnormalities.Count > 0)
{
RemoveAbnormalityInternal(activeAbnormalities[0]);
}
}
[Rpc(SendTo.Server)]
private void RemoveAllAbnormalitiesServerRpc()
{
RemoveAllAbnormalities();
}
/// <summary>
/// 특정 출처의 모든 이상 상태 제거
/// </summary>
public void RemoveAbnormalitiesFromSource(GameObject source)
{
if (!IsServer)
{
var sourceNetId = source != null && source.TryGetComponent<NetworkObject>(out var netObj) ? netObj.NetworkObjectId : 0UL;
RemoveAbnormalitiesFromSourceServerRpc(sourceNetId);
return;
}
for (int i = activeAbnormalities.Count - 1; i >= 0; i--)
{
if (activeAbnormalities[i].Source == source)
{
RemoveAbnormalityInternal(activeAbnormalities[i]);
}
}
}
[Rpc(SendTo.Server)]
private void RemoveAbnormalitiesFromSourceServerRpc(ulong sourceNetworkId)
{
for (int i = activeAbnormalities.Count - 1; i >= 0; i--)
{
var abnormality = activeAbnormalities[i];
var sourceNetId = abnormality.Source != null && abnormality.Source.TryGetComponent<NetworkObject>(out var netObj) ? netObj.NetworkObjectId : 0UL;
if (sourceNetId == sourceNetworkId)
{
RemoveAbnormalityInternal(abnormality);
}
}
}
private void UpdateAbnormalities(float deltaTime)
{
for (int i = activeAbnormalities.Count - 1; i >= 0; i--)
{
var abnormality = activeAbnormalities[i];
if (abnormality.CanTriggerPeriodic())
{
TriggerPeriodicEffect(abnormality);
}
if (abnormality.Tick(deltaTime))
{
RemoveAbnormalityInternal(abnormality);
}
}
}
private void TriggerPeriodicEffect(ActiveAbnormality abnormality)
{
if (networkController == null) return;
float value = abnormality.Data.periodicValue;
if (value > 0)
{
networkController.RestoreHealthRpc(value);
Debug.Log($"[Abnormality] Periodic heal: +{value} HP from {abnormality.Data.abnormalityName}");
}
else if (value < 0)
{
networkController.TakeDamageRpc(-value);
Debug.Log($"[Abnormality] Periodic damage: {-value} HP from {abnormality.Data.abnormalityName}");
}
}
private ActiveAbnormality FindExistingAbnormality(AbnormalityData data)
{
return activeAbnormalities.Find(a => a.Data.abnormalityName == data.abnormalityName);
}
private void ApplyStatModifiers(ActiveAbnormality abnormality)
{
if (characterStats == null) return;
foreach (var entry in abnormality.Data.statModifiers)
{
var stat = characterStats.GetStat(entry.statType);
if (stat != null)
{
var modifier = new StatModifier(entry.value, entry.modType, abnormality);
abnormality.AppliedModifiers.Add(modifier);
stat.AddModifier(modifier);
}
}
}
private void RemoveStatModifiers(ActiveAbnormality abnormality)
{
if (characterStats == null) return;
foreach (StatType statType in Enum.GetValues(typeof(StatType)))
{
var stat = characterStats.GetStat(statType);
stat?.RemoveAllModifiersFromSource(abnormality);
}
abnormality.AppliedModifiers.Clear();
}
private void ApplyControlEffect(AbnormalityData data)
{
switch (data.controlType)
{
case ControlType.Stun:
stunCount++;
break;
case ControlType.Silence:
silenceCount++;
break;
case ControlType.Slow:
slowMultiplier = Mathf.Min(slowMultiplier, data.slowMultiplier);
break;
}
}
private void RemoveControlEffect(AbnormalityData data)
{
switch (data.controlType)
{
case ControlType.Stun:
stunCount = Mathf.Max(0, stunCount - 1);
break;
case ControlType.Silence:
silenceCount = Mathf.Max(0, silenceCount - 1);
break;
case ControlType.Slow:
RecalculateSlowMultiplier();
break;
}
}
private void RecalculateSlowMultiplier()
{
slowMultiplier = 1f;
foreach (var abnormality in activeAbnormalities)
{
if (abnormality.Data.controlType == ControlType.Slow)
{
slowMultiplier = Mathf.Min(slowMultiplier, abnormality.Data.slowMultiplier);
}
}
}
private void SyncAbnormalityAdd(ActiveAbnormality abnormality, GameObject source)
{
var sourceClientId = source != null && source.TryGetComponent<NetworkObject>(out var netObj) ? netObj.OwnerClientId : 0UL;
var syncData = new AbnormalitySyncData
{
AbnormalityId = abnormality.Data.GetInstanceID(),
RemainingDuration = abnormality.RemainingDuration,
SourceClientId = sourceClientId
};
syncedAbnormalities.Add(syncData);
}
private void UpdateSyncedAbnormalityDuration(ActiveAbnormality abnormality)
{
for (int i = 0; i < syncedAbnormalities.Count; i++)
{
if (syncedAbnormalities[i].AbnormalityId == abnormality.Data.GetInstanceID())
{
var syncData = syncedAbnormalities[i];
syncData.RemainingDuration = abnormality.RemainingDuration;
syncedAbnormalities[i] = syncData;
break;
}
}
}
private void SyncAbnormalityRemove(ActiveAbnormality abnormality)
{
for (int i = 0; i < syncedAbnormalities.Count; i++)
{
if (syncedAbnormalities[i].AbnormalityId == abnormality.Data.GetInstanceID())
{
syncedAbnormalities.RemoveAt(i);
break;
}
}
}
private void OnSyncedAbnormalitiesChanged(NetworkListEvent<AbnormalitySyncData> changeEvent)
{
OnAbnormalitiesChanged?.Invoke();
}
private AbnormalityData FindAbnormalityDataById(int instanceId)
{
var allData = Resources.FindObjectsOfTypeAll<AbnormalityData>();
foreach (var data in allData)
{
if (data.GetInstanceID() == instanceId)
return data;
}
return null;
}
/// <summary>
/// 특정 이름의 이상 상태가 활성화되어 있는지 확인
/// </summary>
public bool HasAbnormality(string name)
{
return activeAbnormalities.Exists(a => a.Data.abnormalityName == name);
}
/// <summary>
/// 특정 데이터의 이상 상태가 활성화되어 있는지 확인
/// </summary>
public bool HasAbnormality(AbnormalityData data)
{
return activeAbnormalities.Exists(a => a.Data.abnormalityName == data.abnormalityName);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a766b6ab825c1445a3385079bb32cc5

View File

@@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using Colosseum.Stats;
namespace Colosseum.Abnormalities
{
/// <summary>
/// 런타임 활성 이상 상태 인스턴스
/// AbnormalityData의 인스턴스로, 실제 적용 중인 이상 상태를 관리합니다.
/// </summary>
public class ActiveAbnormality
{
/// <summary>
/// 이상 상태 데이터
/// </summary>
public AbnormalityData Data { get; }
/// <summary>
/// 효과를 건 대상 (버프/디버프 시전자)
/// </summary>
public GameObject Source { get; }
/// <summary>
/// 남은 지속 시간
/// </summary>
public float RemainingDuration { get; set; }
/// <summary>
/// 적용된 스탯 수정자 목록
/// </summary>
public List<StatModifier> AppliedModifiers { get; }
/// <summary>
/// 주기적 효과 타이머
/// </summary>
public float PeriodicTimer { get; set; }
/// <summary>
/// 고유 식별자 (네트워크 동기화용)
/// </summary>
public Guid Id { get; }
/// <summary>
/// 활성 이상 상태 생성
/// </summary>
/// <param name="data">이상 상태 데이터</param>
/// <param name="source">효과 시전자</param>
public ActiveAbnormality(AbnormalityData data, GameObject source)
{
Data = data;
Source = source;
RemainingDuration = data.duration;
PeriodicTimer = 0f;
Id = Guid.NewGuid();
AppliedModifiers = new List<StatModifier>();
}
/// <summary>
/// 지속 시간 갱신
/// </summary>
public void RefreshDuration()
{
RemainingDuration = Data.duration;
PeriodicTimer = 0f;
}
/// <summary>
/// 시간 경과 처리
/// </summary>
/// <param name="deltaTime">경과 시간</param>
/// <returns>효과가 만료되었으면 true</returns>
public bool Tick(float deltaTime)
{
// 영구 효과는 시간 감소 없음
if (Data.IsPermanent)
return false;
RemainingDuration -= deltaTime;
// 주기적 효과 타이머 업데이트
if (Data.HasPeriodicEffect)
{
PeriodicTimer += deltaTime;
}
return RemainingDuration <= 0f;
}
/// <summary>
/// 주기적 효과 발동 가능 여부 확인
/// </summary>
/// <returns>발동 가능하면 true</returns>
public bool CanTriggerPeriodic()
{
if (!Data.HasPeriodicEffect)
return false;
if (PeriodicTimer >= Data.periodicInterval)
{
PeriodicTimer -= Data.periodicInterval;
return true;
}
return false;
}
/// <summary>
/// 진행률 (0~1)
/// </summary>
public float Progress
{
get
{
if (Data.IsPermanent)
return 1f;
return Mathf.Clamp01(1f - (RemainingDuration / Data.duration));
}
}
/// <summary>
/// 남은 시간 비율 (1~0, UI 표시용)
/// </summary>
public float RemainingRatio
{
get
{
if (Data.IsPermanent)
return 1f;
return Mathf.Clamp01(RemainingDuration / Data.duration);
}
}
}
}

View File

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