diff --git a/Assets/Scripts/Weapons.meta b/Assets/Scripts/Weapons.meta new file mode 100644 index 00000000..4253ca13 --- /dev/null +++ b/Assets/Scripts/Weapons.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e113408f0cfce6c4aa60725c3005f25c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Weapons/WeaponData.cs b/Assets/Scripts/Weapons/WeaponData.cs new file mode 100644 index 00000000..f319a139 --- /dev/null +++ b/Assets/Scripts/Weapons/WeaponData.cs @@ -0,0 +1,107 @@ +using UnityEngine; + +using Colosseum.Stats; + +namespace Colosseum.Weapons +{ + /// + /// 무기 장착 위치 + /// + public enum WeaponSlot + { + RightHand, // 오른손 + LeftHand, // 왼손 + Back, // 등 + Hip, // 허리 + TwoHanded, // 양손 + } + + /// + /// 무기 데이터. 무기의 기본 정보, 스탯 보너스, 배율, 외형을 관리합니다. + /// + [CreateAssetMenu(fileName = "NewWeapon", menuName = "Colosseum/Weapon")] + public class WeaponData : ScriptableObject + { + [Header("기본 정보")] + [SerializeField] private string weaponName; + [TextArea(2, 4)] + [SerializeField] private string description; + [SerializeField] private Sprite icon; + + [Header("장착 설정")] + [Tooltip("무기가 장착될 슬롯")] + [SerializeField] private WeaponSlot weaponSlot = WeaponSlot.RightHand; + [Tooltip("무기 프리팹 (메시, 콜라이더 등 포함)")] + [SerializeField] private GameObject weaponPrefab; + [Tooltip("장착 시 위치 오프셋")] + [SerializeField] private Vector3 positionOffset = Vector3.zero; + [Tooltip("장착 시 회전 오프셋 (오일러 각도)")] + [SerializeField] private Vector3 rotationOffset = Vector3.zero; + [Tooltip("장착 시 스케일")] + [SerializeField] private Vector3 scale = Vector3.one; + + [Header("스탯 보너스 (Flat)")] + [Tooltip("힘 보너스")] + [SerializeField] private int strengthBonus = 0; + [Tooltip("민첩 보너스")] + [SerializeField] private int dexterityBonus = 0; + [Tooltip("지능 보너스")] + [SerializeField] private int intelligenceBonus = 0; + [Tooltip("활력 보너스")] + [SerializeField] private int vitalityBonus = 0; + [Tooltip("지혜 보너스")] + [SerializeField] private int wisdomBonus = 0; + [Tooltip("정신 보너스")] + [SerializeField] private int spiritBonus = 0; + + [Header("배율")] + [Tooltip("데미지 배율 (1.0 = 100%, 1.5 = 150%)")] + [Min(0f)] [SerializeField] private float damageMultiplier = 1f; + [Tooltip("사거리 배율 (1.0 = 100%, 1.2 = 120%)")] + [Min(0f)] [SerializeField] private float rangeMultiplier = 1f; + [Tooltip("마나 소모 배율 (1.0 = 100%, 0.8 = 80%)")] + [Min(0f)] [SerializeField] private float manaCostMultiplier = 1f; + + // Properties - 기본 정보 + public string WeaponName => weaponName; + public string Description => description; + public Sprite Icon => icon; + + // Properties - 장착 설정 + public WeaponSlot WeaponSlot => weaponSlot; + public GameObject WeaponPrefab => weaponPrefab; + public Vector3 PositionOffset => positionOffset; + public Vector3 RotationOffset => rotationOffset; + public Vector3 Scale => scale; + + // Properties - 스탯 보너스 + public int StrengthBonus => strengthBonus; + public int DexterityBonus => dexterityBonus; + public int IntelligenceBonus => intelligenceBonus; + public int VitalityBonus => vitalityBonus; + public int WisdomBonus => wisdomBonus; + public int SpiritBonus => spiritBonus; + + // Properties - 배율 + public float DamageMultiplier => damageMultiplier; + public float RangeMultiplier => rangeMultiplier; + public float ManaCostMultiplier => manaCostMultiplier; + + /// + /// 스탯 타입에 해당하는 보너스 값 반환 + /// + public int GetStatBonus(StatType statType) + { + return statType switch + { + StatType.Strength => strengthBonus, + StatType.Dexterity => dexterityBonus, + StatType.Intelligence => intelligenceBonus, + StatType.Vitality => vitalityBonus, + StatType.Wisdom => wisdomBonus, + StatType.Spirit => spiritBonus, + _ => 0, + }; + } + } +} diff --git a/Assets/Scripts/Weapons/WeaponEquipment.cs b/Assets/Scripts/Weapons/WeaponEquipment.cs new file mode 100644 index 00000000..80093979 --- /dev/null +++ b/Assets/Scripts/Weapons/WeaponEquipment.cs @@ -0,0 +1,401 @@ +using System; + +using UnityEngine; +using Unity.Netcode; + +using Colosseum.Stats; + +namespace Colosseum.Weapons +{ + /// + /// 무기 장착을 관리하는 컴포넌트. + /// 무기 장착 시 스탯 보너스를 적용하고 배율을 제공하며, 무기 외형을 표시합니다. + /// 메시 이름으로 소켓을 자동 검색합니다. + /// + public class WeaponEquipment : NetworkBehaviour + { + [Header("References")] + [Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")] + [SerializeField] private CharacterStats characterStats; + + [Header("Socket Names (메시 이름)")] + [Tooltip("오른손 메시 이름")] + [SerializeField] private string rightHandName = "Hand_R"; + [Tooltip("왼손 메시 이름")] + [SerializeField] private string leftHandName = "Hand_L"; + [Tooltip("등 메시 이름")] + [SerializeField] private string backName = "Spine"; + [Tooltip("허리 메시 이름")] + [SerializeField] private string hipName = "Hip"; + [Tooltip("양손 메시 이름 (기본값: 오른손 사용)")] + [SerializeField] private string twoHandedName = ""; + + [Header("Starting Weapon")] + [Tooltip("시작 무기 (선택)")] + [SerializeField] private WeaponData startingWeapon; + + // 캐싱된 소켓 Transform들 + private Transform rightHandSocket; + private Transform leftHandSocket; + private Transform backSocket; + private Transform hipSocket; + private Transform twoHandedSocket; + + // 현재 장착 중인 무기 + private WeaponData currentWeapon; + + // 현재 생성된 무기 인스턴스 + private GameObject currentWeaponInstance; + + // 현재 적용된 스탯 수정자들 (해제 시 제거용) + private readonly System.Collections.Generic.Dictionary activeModifiers + = new System.Collections.Generic.Dictionary(); + + // 무기 장착 상태 동기화 + private NetworkVariable equippedWeaponId = new NetworkVariable(-1); + + public WeaponData CurrentWeapon => currentWeapon; + public bool HasWeaponEquipped => currentWeapon != null; + public GameObject CurrentWeaponInstance => currentWeaponInstance; + + // 배율 프로퍼티 (무기 없으면 기본값 1.0) + public float DamageMultiplier => currentWeapon != null ? currentWeapon.DamageMultiplier : 1f; + public float RangeMultiplier => currentWeapon != null ? currentWeapon.RangeMultiplier : 1f; + public float ManaCostMultiplier => currentWeapon != null ? currentWeapon.ManaCostMultiplier : 1f; + + // 이벤트 + public event Action OnWeaponEquipped; + public event Action OnWeaponUnequipped; + + private void Awake() + { + // CharacterStats 참조 확인 + if (characterStats == null) + { + characterStats = GetComponent(); + } + + // 소켓 자동 검색 + CacheSockets(); + } + + public override void OnNetworkSpawn() + { + // 네트워크 변수 변경 콜백 + equippedWeaponId.OnValueChanged += HandleEquippedWeaponChanged; + + // 서버에서 시작 무기 장착 + if (IsServer && startingWeapon != null) + { + EquipWeapon(startingWeapon); + } + } + + public override void OnNetworkDespawn() + { + equippedWeaponId.OnValueChanged -= HandleEquippedWeaponChanged; + } + + /// + /// 메시 이름으로 소켓 Transform 캐싱 + /// + private void CacheSockets() + { + rightHandSocket = FindDeepChild(rightHandName); + leftHandSocket = FindDeepChild(leftHandName); + backSocket = FindDeepChild(backName); + hipSocket = FindDeepChild(hipName); + + // 양손은 별도 이름 없으면 오른손 사용 + if (!string.IsNullOrEmpty(twoHandedName)) + { + twoHandedSocket = FindDeepChild(twoHandedName); + } + else + { + twoHandedSocket = rightHandSocket; + } + + Debug.Log($"[WeaponEquipment] Sockets cached - R:{rightHandSocket != null}, L:{leftHandSocket != null}, Back:{backSocket != null}, Hip:{hipSocket != null}"); + } + + /// + /// 이름으로 자식 Transform 재귀 검색 + /// + private Transform FindDeepChild(string name) + { + if (string.IsNullOrEmpty(name)) return null; + + // BFS로 검색 + var queue = new System.Collections.Generic.Queue(); + queue.Enqueue(transform); + + while (queue.Count > 0) + { + Transform current = queue.Dequeue(); + + if (current.name == name) + { + return current; + } + + foreach (Transform child in current) + { + queue.Enqueue(child); + } + } + + return null; + } + + private void HandleEquippedWeaponChanged(int oldValue, int newValue) + { + // -1이면 무기 해제, 그 외에는 무기 장착됨 + // (GetInstanceID()는 음수를 반환할 수 있으므로 >= 0 체크 사용 불가) + if (newValue == -1) + { + UnequipWeaponInternal(); + } + // 클라이언트에서는 서버에서 이미 장착된 무기 정보를 받아야 함 + // TODO: WeaponDatabase에서 ID로 WeaponData 조회 + } + + + /// + /// 무기 장착 (서버에서만 호출) + /// + public void EquipWeapon(WeaponData weapon) + { + if (weapon == null) + { + Debug.LogWarning("[WeaponEquipment] EquipWeapon called with null weapon"); + return; + } + + if (!IsServer) + { + Debug.LogWarning("[WeaponEquipment] EquipWeapon can only be called on server"); + return; + } + + // 기존 무기 해제 + if (currentWeapon != null) + { + UnequipWeapon(); + } + + currentWeapon = weapon; + + // 스탯 보너스 적용 + ApplyStatBonuses(weapon); + + // 무기 외형 생성 및 부착 + SpawnWeaponVisuals(weapon); + + // 네트워크 동기화 (간단한 ID 사용, 실제로는 WeaponDatabase 필요) + equippedWeaponId.Value = weapon.GetInstanceID(); + + // 이벤트 발생 + OnWeaponEquipped?.Invoke(weapon); + + Debug.Log($"[WeaponEquipment] Equipped: {weapon.WeaponName} at {weapon.WeaponSlot}"); + } + + /// + /// 무기 해제 (서버에서만 호출) + /// + public void UnequipWeapon() + { + if (currentWeapon == null) return; + + if (!IsServer) + { + Debug.LogWarning("[WeaponEquipment] UnequipWeapon can only be called on server"); + return; + } + + WeaponData previousWeapon = currentWeapon; + + // 스탯 보너스 제거 + RemoveStatBonuses(); + + // 무기 외형 제거 + DespawnWeaponVisuals(); + + currentWeapon = null; + equippedWeaponId.Value = -1; + + // 이벤트 발생 + OnWeaponUnequipped?.Invoke(previousWeapon); + + Debug.Log($"[WeaponEquipment] Unequipped: {previousWeapon.WeaponName}"); + } + + /// + /// 내부 해제 로직 (클라이언트 동기화용) + /// + private void UnequipWeaponInternal() + { + if (currentWeapon == null) return; + + WeaponData previousWeapon = currentWeapon; + RemoveStatBonuses(); + DespawnWeaponVisuals(); + currentWeapon = null; + + OnWeaponUnequipped?.Invoke(previousWeapon); + } + + /// + /// 무기의 스탯 보너스 적용 + /// + private void ApplyStatBonuses(WeaponData weapon) + { + if (characterStats == null) return; + + // 모든 스탯 타입에 대해 보너스 적용 + foreach (StatType statType in System.Enum.GetValues(typeof(StatType))) + { + int bonus = weapon.GetStatBonus(statType); + if (bonus != 0) + { + var stat = characterStats.GetStat(statType); + if (stat != null) + { + var modifier = new StatModifier(bonus, StatModType.Flat, weapon); + stat.AddModifier(modifier); + activeModifiers[statType] = modifier; + + Debug.Log($"[WeaponEquipment] Applied {statType} +{bonus}"); + } + } + } + } + + /// + /// 무기의 스탯 보너스 제거 + /// + private void RemoveStatBonuses() + { + if (characterStats == null) return; + + // 각 스탯에서 무기로부터 추가된 수정자 제거 + foreach (StatType statType in System.Enum.GetValues(typeof(StatType))) + { + var stat = characterStats.GetStat(statType); + if (stat != null && activeModifiers.TryGetValue(statType, out StatModifier modifier)) + { + stat.RemoveModifier(modifier); + Debug.Log($"[WeaponEquipment] Removed {statType} modifier"); + } + } + + activeModifiers.Clear(); + } + + /// + /// 무기 외형 생성 및 부착 + /// + private void SpawnWeaponVisuals(WeaponData weapon) + { + if (weapon == null || weapon.WeaponPrefab == null) return; + + // 적절한 소켓 찾기 + Transform socket = GetSocketForSlot(weapon.WeaponSlot); + if (socket == null) + { + Debug.LogWarning($"[WeaponEquipment] No socket found for slot: {weapon.WeaponSlot}"); + return; + } + + // 무기 인스턴스 생성 + currentWeaponInstance = Instantiate(weapon.WeaponPrefab, socket); + currentWeaponInstance.transform.localPosition = weapon.PositionOffset; + currentWeaponInstance.transform.localRotation = Quaternion.Euler(weapon.RotationOffset); + + // 소켓 스케일 보정 (부모 스케일이 작은 경우 무기도 작아지는 문제 해결) + Vector3 scaleCompensation = new Vector3( + socket.lossyScale.x != 0 ? 1f / socket.lossyScale.x : 1f, + socket.lossyScale.y != 0 ? 1f / socket.lossyScale.y : 1f, + socket.lossyScale.z != 0 ? 1f / socket.lossyScale.z : 1f + ); + currentWeaponInstance.transform.localScale = Vector3.Scale(weapon.Scale, scaleCompensation); + currentWeaponInstance = Instantiate(weapon.WeaponPrefab, socket); + currentWeaponInstance.transform.localPosition = weapon.PositionOffset; + currentWeaponInstance.transform.localRotation = Quaternion.Euler(weapon.RotationOffset); + currentWeaponInstance.transform.localScale = weapon.Scale; + + // 디버그: 스케일 정보 출력 + Debug.Log($"[WeaponEquipment] Weapon instantiated - LocalScale: {currentWeaponInstance.transform.localScale}, LossyScale: {currentWeaponInstance.transform.lossyScale}"); + Debug.Log($"[WeaponEquipment] Socket: {socket.name}, Socket scale: {socket.lossyScale}"); + Debug.Log($"[WeaponEquipment] Position offset: {weapon.PositionOffset}, Rotation offset: {weapon.RotationOffset}"); + + // 네트워크 동기화를 위해 Spawn (서버에서만) + if (IsServer && currentWeaponInstance.TryGetComponent(out var networkObject)) + { + networkObject.Spawn(true); + } + + Debug.Log($"[WeaponEquipment] Spawned weapon visual: {weapon.WeaponName}"); + } + + /// + /// 무기 외형 제거 + /// + private void DespawnWeaponVisuals() + { + if (currentWeaponInstance == null) return; + + // 네트워크 Object면 Despawn + if (currentWeaponInstance.TryGetComponent(out var networkObject) && networkObject.IsSpawned) + { + if (IsServer) + { + networkObject.Despawn(true); + } + } + else + { + Destroy(currentWeaponInstance); + } + + currentWeaponInstance = null; + Debug.Log("[WeaponEquipment] Despawned weapon visual"); + } + + /// + /// 슬롯 타입에 맞는 소켓 Transform 반환 + /// + private Transform GetSocketForSlot(WeaponSlot slot) + { + return slot switch + { + WeaponSlot.RightHand => rightHandSocket, + WeaponSlot.LeftHand => leftHandSocket, + WeaponSlot.Back => backSocket, + WeaponSlot.Hip => hipSocket, + WeaponSlot.TwoHanded => twoHandedSocket != null ? twoHandedSocket : rightHandSocket, + _ => rightHandSocket, + }; + } + + /// + /// 서버에 무기 장착 요청 + /// + [Rpc(SendTo.Server)] + public void RequestEquipWeaponRpc(int weaponInstanceId) + { + // TODO: WeaponDatabase에서 ID로 WeaponData 조회 후 EquipWeapon 호출 + Debug.Log($"[WeaponEquipment] Client requested weapon equip: {weaponInstanceId}"); + } + + /// + /// 서버에 무기 해제 요청 + /// + [Rpc(SendTo.Server)] + public void RequestUnequipWeaponRpc() + { + UnequipWeapon(); + } + } +}