diff --git a/Assets/_Game/Data/Weapons/Data_Weapon_검.asset b/Assets/_Game/Data/Weapons/Data_Weapon_검.asset index 5accc40c..b70506b9 100644 --- a/Assets/_Game/Data/Weapons/Data_Weapon_검.asset +++ b/Assets/_Game/Data/Weapons/Data_Weapon_검.asset @@ -15,6 +15,7 @@ MonoBehaviour: weaponName: "\uAC80" description: icon: {fileID: 21300000, guid: 70e07ab1b9f326b4bae30fe839d609ea, type: 3} + weaponTrait: 1 weaponSlot: 0 weaponPrefab: {fileID: 1631607032641582, guid: 888f3d986f4ea1a4491c1ffca7e660c9, type: 3} positionOffset: {x: 0, y: 0, z: 0} diff --git a/Assets/_Game/Scripts/Player/PlayerSkillInput.cs b/Assets/_Game/Scripts/Player/PlayerSkillInput.cs index 75674a27..38196d7e 100644 --- a/Assets/_Game/Scripts/Player/PlayerSkillInput.cs +++ b/Assets/_Game/Scripts/Player/PlayerSkillInput.cs @@ -307,6 +307,13 @@ namespace Colosseum.Player return; } + // 무기 trait 제약 검증 + if (weaponEquipment != null && !skill.MatchesWeaponTrait(weaponEquipment.EquippedWeaponTraits)) + { + Debug.Log($"무기 조건 불충족: {skill.SkillName}"); + return; + } + // 마나 비용 체크 (무기 배율 적용) float actualManaCost = GetActualManaCost(loadoutEntry); if (networkController != null && networkController.Mana < actualManaCost) @@ -352,6 +359,10 @@ namespace Colosseum.Player if (skillController.IsExecutingSkill || skillController.IsOnCooldown(skill)) return; + // 무기 trait 제약 검증 + if (weaponEquipment != null && !skill.MatchesWeaponTrait(weaponEquipment.EquippedWeaponTraits)) + return; + // 마나 비용 체크 (무기 배율 적용) float actualManaCost = GetActualManaCost(loadoutEntry); if (networkController != null && networkController.Mana < actualManaCost) @@ -578,6 +589,10 @@ namespace Colosseum.Player if (actionState != null && !actionState.CanStartSkill(skill)) return false; + // 무기 trait 제약 검증 + if (weaponEquipment != null && !skill.MatchesWeaponTrait(weaponEquipment.EquippedWeaponTraits)) + return false; + return !skillController.IsOnCooldown(skill) && !skillController.IsExecutingSkill; } @@ -627,6 +642,13 @@ namespace Colosseum.Player return false; } + // 무기 trait 제약 검증 + if (weaponEquipment != null && !skill.MatchesWeaponTrait(weaponEquipment.EquippedWeaponTraits)) + { + Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: 무기 조건 불충족"); + return false; + } + if (skillController == null || skillController.IsExecutingSkill || skillController.IsOnCooldown(skill)) { string reason = skillController == null ? "skillController=null" : diff --git a/Assets/_Game/Scripts/Skills/SkillData.cs b/Assets/_Game/Scripts/Skills/SkillData.cs index 00fdb7fb..d8396cbb 100644 --- a/Assets/_Game/Scripts/Skills/SkillData.cs +++ b/Assets/_Game/Scripts/Skills/SkillData.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using UnityEngine; +using Colosseum.Weapons; + namespace Colosseum.Skills { /// @@ -94,6 +96,10 @@ namespace Colosseum.Skills [Tooltip("시전 중 다른 스킬 입력 차단 여부")] [SerializeField] private bool blockOtherSkillsWhileCasting = true; + [Header("무기 조건")] + [Tooltip("이 스킬 사용에 필요한 무기 특성. None이면 제약 없음.")] + [SerializeField] private WeaponTrait allowedWeaponTraits = WeaponTrait.None; + [Header("쿨타임 & 비용")] [Min(0f)] [SerializeField] private float cooldown = 1f; [Min(0f)] [SerializeField] private float manaCost = 0f; @@ -131,6 +137,7 @@ namespace Colosseum.Skills public bool BlockOtherSkillsWhileCasting => blockOtherSkillsWhileCasting; public IReadOnlyList CastStartEffects => castStartEffects; public IReadOnlyList Effects => effects; + public WeaponTrait AllowedWeaponTraits => allowedWeaponTraits; /// /// 지정한 장착 조건과 현재 스킬 분류가 맞는지 확인합니다. @@ -147,6 +154,18 @@ namespace Colosseum.Skills return matchesRole && matchesActivationType; } + + /// + /// 지정한 무기 특성 조합이 이 스킬의 무기 조건을 충족하는지 확인합니다. + /// allowedWeaponTraits가 None이면 항상 true입니다. + /// + public bool MatchesWeaponTrait(WeaponTrait equippedTraits) + { + if (allowedWeaponTraits == WeaponTrait.None) + return true; + + return (equippedTraits & allowedWeaponTraits) == allowedWeaponTraits; + } } /// diff --git a/Assets/_Game/Scripts/UI/SkillSlotUI.cs b/Assets/_Game/Scripts/UI/SkillSlotUI.cs index c45bcde0..faf0ad2d 100644 --- a/Assets/_Game/Scripts/UI/SkillSlotUI.cs +++ b/Assets/_Game/Scripts/UI/SkillSlotUI.cs @@ -20,6 +20,7 @@ namespace Colosseum.UI [SerializeField] private Color availableColor = Color.white; [SerializeField] private Color cooldownColor = new Color(0.2f, 0.2f, 0.2f, 0.9f); [SerializeField] private Color noManaColor = new Color(0.5f, 0.2f, 0.2f, 0.8f); + [SerializeField] private Color weaponIncompatibleColor = new Color(0.3f, 0.3f, 0.3f, 0.8f); private SkillData skill; private int slotIndex; @@ -88,7 +89,7 @@ namespace Colosseum.UI } } - public void UpdateState(float cooldownRemaining, float cooldownTotal, bool hasEnoughMana) + public void UpdateState(float cooldownRemaining, float cooldownTotal, bool hasEnoughMana, bool isWeaponIncompatible = false) { if (skill == null) { @@ -133,7 +134,8 @@ namespace Colosseum.UI // 쿨다운 완료 if (useIconForCooldown && iconImage != null) { - iconImage.color = hasEnoughMana ? availableColor : noManaColor; + iconImage.color = isWeaponIncompatible ? weaponIncompatibleColor + : hasEnoughMana ? availableColor : noManaColor; } else if (cooldownOverlay != null) { diff --git a/Assets/_Game/Scripts/Weapons/WeaponData.cs b/Assets/_Game/Scripts/Weapons/WeaponData.cs index f319a139..9f2cdfac 100644 --- a/Assets/_Game/Scripts/Weapons/WeaponData.cs +++ b/Assets/_Game/Scripts/Weapons/WeaponData.cs @@ -1,9 +1,26 @@ +using System; + using UnityEngine; using Colosseum.Stats; namespace Colosseum.Weapons { + /// + /// 무기 특성. 스킬 사용 조건 제약에 사용합니다. + /// 여러 특성을 동시에 가질 수 있습니다. + /// + [Flags] + public enum WeaponTrait + { + None = 0, + Melee = 1 << 0, // 근접 (한손검, 양손검, 완드, 스태프) + TwoHanded = 1 << 1, // 양손 (양손검, 스태프) + Defense = 1 << 2, // 방어 (방패) + Magic = 1 << 3, // 마법 (완드, 스태프) + Ranged = 1 << 4, // 사격 (활) + } + /// /// 무기 장착 위치 /// @@ -29,6 +46,8 @@ namespace Colosseum.Weapons [SerializeField] private Sprite icon; [Header("장착 설정")] + [Tooltip("무기의 특성 (근접/양손/방어/마법/사격). 스킬 사용 조건에 사용됩니다.")] + [SerializeField] private WeaponTrait weaponTrait = WeaponTrait.Melee; [Tooltip("무기가 장착될 슬롯")] [SerializeField] private WeaponSlot weaponSlot = WeaponSlot.RightHand; [Tooltip("무기 프리팹 (메시, 콜라이더 등 포함)")] @@ -68,6 +87,7 @@ namespace Colosseum.Weapons public Sprite Icon => icon; // Properties - 장착 설정 + public WeaponTrait WeaponTrait => weaponTrait; public WeaponSlot WeaponSlot => weaponSlot; public GameObject WeaponPrefab => weaponPrefab; public Vector3 PositionOffset => positionOffset; diff --git a/Assets/_Game/Scripts/Weapons/WeaponEquipment.cs b/Assets/_Game/Scripts/Weapons/WeaponEquipment.cs index 0188744e..7e0bf019 100644 --- a/Assets/_Game/Scripts/Weapons/WeaponEquipment.cs +++ b/Assets/_Game/Scripts/Weapons/WeaponEquipment.cs @@ -34,10 +34,18 @@ namespace Colosseum.Weapons [Tooltip("시작 무기 (선택)")] [SerializeField] private WeaponData startingWeapon; + [Header("Starting Offhand")] + [Tooltip("시작 보조 무기 (선택)")] + [SerializeField] private WeaponData startingOffhandWeapon; + [Header("네트워크 동기화")] [Tooltip("이 장착 시스템이 사용하는 모든 WeaponData 목록. 서버→클라이언트 무기 동기화에 사용됩니다.")] [SerializeField] private System.Collections.Generic.List registeredWeapons = new(); + [Header("네트워크 동기화 (보조손)")] + [Tooltip("보조손 WeaponData 목록. 서버→클라이언트 동기화에 사용됩니다.")] + [SerializeField] private System.Collections.Generic.List registeredOffhands = new(); + // 캐싱된 소켓 Transform들 private Transform rightHandSocket; private Transform leftHandSocket; @@ -55,6 +63,15 @@ namespace Colosseum.Weapons private readonly System.Collections.Generic.Dictionary activeModifiers = new System.Collections.Generic.Dictionary(); + // 보조손 무기 + private WeaponData currentOffhandWeapon; + private GameObject currentOffhandWeaponInstance; + private readonly System.Collections.Generic.Dictionary activeOffhandModifiers + = new System.Collections.Generic.Dictionary(); + + // 보조손 네트워크 동기화 (registeredOffhands 인덱스, -1 = 없음) + private NetworkVariable equippedOffhandId = new NetworkVariable(-1); + // 무기 장착 상태 동기화 (registeredWeapons 인덱스, -1 = 없음) private NetworkVariable equippedWeaponId = new NetworkVariable(-1); @@ -67,9 +84,29 @@ namespace Colosseum.Weapons public float RangeMultiplier => currentWeapon != null ? currentWeapon.RangeMultiplier : 1f; public float ManaCostMultiplier => currentWeapon != null ? currentWeapon.ManaCostMultiplier : 1f; + // 보조손 프로퍼티 + public WeaponData CurrentOffhandWeapon => currentOffhandWeapon; + public bool HasOffhandEquipped => currentOffhandWeapon != null; + + /// + /// 현재 장착된 무기(메인+보조)의 특성 합산 + /// + public WeaponTrait EquippedWeaponTraits + { + get + { + WeaponTrait traits = currentWeapon != null ? currentWeapon.WeaponTrait : WeaponTrait.None; + if (currentOffhandWeapon != null) + traits |= currentOffhandWeapon.WeaponTrait; + return traits; + } + } + // 이벤트 public event Action OnWeaponEquipped; public event Action OnWeaponUnequipped; + public event Action OnOffhandWeaponEquipped; + public event Action OnOffhandWeaponUnequipped; private void Awake() { @@ -86,6 +123,7 @@ namespace Colosseum.Weapons public override void OnNetworkSpawn() { equippedWeaponId.OnValueChanged += HandleEquippedWeaponChanged; + equippedOffhandId.OnValueChanged += HandleEquippedOffhandChanged; if (IsServer && startingWeapon != null) { @@ -96,11 +134,21 @@ namespace Colosseum.Weapons // 늦게 접속한 클라이언트: 현재 장착된 무기 시각화 SpawnWeaponVisualsLocal(equippedWeaponId.Value); } + + if (IsServer && startingOffhandWeapon != null) + { + EquipOffhandWeapon(startingOffhandWeapon); + } + else if (!IsServer && equippedOffhandId.Value >= 0) + { + SpawnOffhandVisualsLocal(equippedOffhandId.Value); + } } public override void OnNetworkDespawn() { equippedWeaponId.OnValueChanged -= HandleEquippedWeaponChanged; + equippedOffhandId.OnValueChanged -= HandleEquippedOffhandChanged; } /// @@ -238,6 +286,211 @@ namespace Colosseum.Weapons Debug.Log($"[WeaponEquipment] Unequipped: {previousWeapon.WeaponName}"); } + #region 보조손 + + private void HandleEquippedOffhandChanged(int oldValue, int newValue) + { + if (IsServer) return; + + if (newValue == -1) + UnequipOffhandWeaponInternal(); + else + SpawnOffhandVisualsLocal(newValue); + } + + /// + /// 보조손 무기 장착 (서버에서만 호출) + /// + public void EquipOffhandWeapon(WeaponData weapon) + { + if (weapon == null) + { + Debug.LogWarning("[WeaponEquipment] EquipOffhandWeapon called with null weapon"); + return; + } + + if (!IsServer) + { + Debug.LogWarning("[WeaponEquipment] EquipOffhandWeapon can only be called on server"); + return; + } + + if (currentOffhandWeapon != null) + { + UnequipOffhandWeapon(); + } + + currentOffhandWeapon = weapon; + + ApplyOffhandStatBonuses(weapon); + SpawnOffhandVisuals(weapon); + + equippedOffhandId.Value = registeredOffhands.IndexOf(weapon); + if (equippedOffhandId.Value < 0) + Debug.LogWarning($"[WeaponEquipment] '{weapon.WeaponName}' is not in registeredOffhands. Add it to sync to clients."); + + OnOffhandWeaponEquipped?.Invoke(weapon); + + Debug.Log($"[WeaponEquipment] Equipped offhand: {weapon.WeaponName}"); + } + + /// + /// 보조손 무기 해제 (서버에서만 호출) + /// + public void UnequipOffhandWeapon() + { + if (currentOffhandWeapon == null) return; + + if (!IsServer) + { + Debug.LogWarning("[WeaponEquipment] UnequipOffhandWeapon can only be called on server"); + return; + } + + WeaponData previousWeapon = currentOffhandWeapon; + + RemoveOffhandStatBonuses(); + DespawnOffhandVisuals(); + + currentOffhandWeapon = null; + equippedOffhandId.Value = -1; + + OnOffhandWeaponUnequipped?.Invoke(previousWeapon); + + Debug.Log($"[WeaponEquipment] Unequipped offhand: {previousWeapon.WeaponName}"); + } + + /// + /// 보조손 내부 해제 로직 (클라이언트 동기화용) + /// + private void UnequipOffhandWeaponInternal() + { + if (currentOffhandWeaponInstance == null && currentOffhandWeapon == null) return; + + WeaponData previousWeapon = currentOffhandWeapon; + DespawnOffhandVisualsLocal(); + + OnOffhandWeaponUnequipped?.Invoke(previousWeapon); + } + + /// + /// 보조손 무기의 스탯 보너스 적용 + /// + private void ApplyOffhandStatBonuses(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); + activeOffhandModifiers[statType] = modifier; + + Debug.Log($"[WeaponEquipment] Applied offhand {statType} +{bonus}"); + } + } + } + } + + /// + /// 보조손 무기의 스탯 보너스 제거 + /// + private void RemoveOffhandStatBonuses() + { + if (characterStats == null) return; + + foreach (StatType statType in System.Enum.GetValues(typeof(StatType))) + { + var stat = characterStats.GetStat(statType); + if (stat != null && activeOffhandModifiers.TryGetValue(statType, out StatModifier modifier)) + { + stat.RemoveModifier(modifier); + Debug.Log($"[WeaponEquipment] Removed offhand {statType} modifier"); + } + } + + activeOffhandModifiers.Clear(); + } + + /// + /// 보조손 무기 외형 생성 및 부착 (서버). 항상 왼손에 장착합니다. + /// + private void SpawnOffhandVisuals(WeaponData weapon) + { + if (weapon == null || weapon.WeaponPrefab == null) return; + + if (leftHandSocket == null) + { + Debug.LogWarning("[WeaponEquipment] No left hand socket found for offhand weapon"); + return; + } + + currentOffhandWeaponInstance = Instantiate(weapon.WeaponPrefab, leftHandSocket); + currentOffhandWeaponInstance.transform.localPosition = weapon.PositionOffset; + currentOffhandWeaponInstance.transform.localRotation = Quaternion.Euler(weapon.RotationOffset); + currentOffhandWeaponInstance.transform.localScale = weapon.Scale; + + Debug.Log($"[WeaponEquipment] Spawned offhand visual: {weapon.WeaponName}"); + } + + /// + /// 클라이언트: registeredOffhands 인덱스로 보조손 무기 외형 생성 + /// + private void SpawnOffhandVisualsLocal(int weaponIndex) + { + if (weaponIndex < 0 || weaponIndex >= registeredOffhands.Count || registeredOffhands[weaponIndex] == null) + { + Debug.LogWarning($"[WeaponEquipment] Offhand weapon index {weaponIndex} not found in registeredOffhands."); + return; + } + + var weapon = registeredOffhands[weaponIndex]; + if (weapon.WeaponPrefab == null) return; + + DespawnOffhandVisualsLocal(); + + if (leftHandSocket == null) + { + Debug.LogWarning("[WeaponEquipment] No left hand socket found for offhand weapon"); + return; + } + + currentOffhandWeaponInstance = Instantiate(weapon.WeaponPrefab, leftHandSocket); + currentOffhandWeaponInstance.transform.localPosition = weapon.PositionOffset; + currentOffhandWeaponInstance.transform.localRotation = Quaternion.Euler(weapon.RotationOffset); + currentOffhandWeaponInstance.transform.localScale = weapon.Scale; + currentOffhandWeapon = weapon; + } + + /// + /// 보조손 무기 외형 제거 (서버) + /// + private void DespawnOffhandVisuals() + { + if (currentOffhandWeaponInstance == null) return; + Destroy(currentOffhandWeaponInstance); + currentOffhandWeaponInstance = null; + } + + /// + /// 보조손 무기 외형 제거 (클라이언트) + /// + private void DespawnOffhandVisualsLocal() + { + if (currentOffhandWeaponInstance == null) return; + Destroy(currentOffhandWeaponInstance); + currentOffhandWeaponInstance = null; + currentOffhandWeapon = null; + } + + #endregion + /// /// 내부 해제 로직 (클라이언트 동기화용) ///