feat: 무기 특성(WeaponTrait) 시스템 및 보조손 장착 구현
- WeaponTrait enum 추가 (Melee/TwoHanded/Defense/Magic/Ranged) - SkillData에 allowedWeaponTraits 필드 및 MatchesWeaponTrait() 검증 메서드 추가 - WeaponEquipment에 보조손(오프핸드) 슬롯 지원 (장착/해제/스탯 보너스/네트워크 동기화) - EquippedWeaponTraits 프로퍼티로 메인+보조 무기 특성 합산 제공 - PlayerSkillInput 4곳(OnSkillInput/RPC/CanUseSkill/DebugExecute)에 무기 trait 제약 검증 추가 - SkillSlotUI에 무기 불호환 시 회색 표시(weaponIncompatibleColor) 지원 - 기존 검 에셋에 weaponTrait=Melee 설정
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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" :
|
||||
|
||||
@@ -3,6 +3,8 @@ using System.Collections.Generic;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.Weapons;
|
||||
|
||||
namespace Colosseum.Skills
|
||||
{
|
||||
/// <summary>
|
||||
@@ -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<SkillEffect> CastStartEffects => castStartEffects;
|
||||
public IReadOnlyList<SkillEffect> Effects => effects;
|
||||
public WeaponTrait AllowedWeaponTraits => allowedWeaponTraits;
|
||||
|
||||
/// <summary>
|
||||
/// 지정한 장착 조건과 현재 스킬 분류가 맞는지 확인합니다.
|
||||
@@ -147,6 +154,18 @@ namespace Colosseum.Skills
|
||||
|
||||
return matchesRole && matchesActivationType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지정한 무기 특성 조합이 이 스킬의 무기 조건을 충족하는지 확인합니다.
|
||||
/// allowedWeaponTraits가 None이면 항상 true입니다.
|
||||
/// </summary>
|
||||
public bool MatchesWeaponTrait(WeaponTrait equippedTraits)
|
||||
{
|
||||
if (allowedWeaponTraits == WeaponTrait.None)
|
||||
return true;
|
||||
|
||||
return (equippedTraits & allowedWeaponTraits) == allowedWeaponTraits;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
using System;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.Stats;
|
||||
|
||||
namespace Colosseum.Weapons
|
||||
{
|
||||
/// <summary>
|
||||
/// 무기 특성. 스킬 사용 조건 제약에 사용합니다.
|
||||
/// 여러 특성을 동시에 가질 수 있습니다.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum WeaponTrait
|
||||
{
|
||||
None = 0,
|
||||
Melee = 1 << 0, // 근접 (한손검, 양손검, 완드, 스태프)
|
||||
TwoHanded = 1 << 1, // 양손 (양손검, 스태프)
|
||||
Defense = 1 << 2, // 방어 (방패)
|
||||
Magic = 1 << 3, // 마법 (완드, 스태프)
|
||||
Ranged = 1 << 4, // 사격 (활)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 무기 장착 위치
|
||||
/// </summary>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<WeaponData> registeredWeapons = new();
|
||||
|
||||
[Header("네트워크 동기화 (보조손)")]
|
||||
[Tooltip("보조손 WeaponData 목록. 서버→클라이언트 동기화에 사용됩니다.")]
|
||||
[SerializeField] private System.Collections.Generic.List<WeaponData> registeredOffhands = new();
|
||||
|
||||
// 캐싱된 소켓 Transform들
|
||||
private Transform rightHandSocket;
|
||||
private Transform leftHandSocket;
|
||||
@@ -55,6 +63,15 @@ namespace Colosseum.Weapons
|
||||
private readonly System.Collections.Generic.Dictionary<StatType, StatModifier> activeModifiers
|
||||
= new System.Collections.Generic.Dictionary<StatType, StatModifier>();
|
||||
|
||||
// 보조손 무기
|
||||
private WeaponData currentOffhandWeapon;
|
||||
private GameObject currentOffhandWeaponInstance;
|
||||
private readonly System.Collections.Generic.Dictionary<StatType, StatModifier> activeOffhandModifiers
|
||||
= new System.Collections.Generic.Dictionary<StatType, StatModifier>();
|
||||
|
||||
// 보조손 네트워크 동기화 (registeredOffhands 인덱스, -1 = 없음)
|
||||
private NetworkVariable<int> equippedOffhandId = new NetworkVariable<int>(-1);
|
||||
|
||||
// 무기 장착 상태 동기화 (registeredWeapons 인덱스, -1 = 없음)
|
||||
private NetworkVariable<int> equippedWeaponId = new NetworkVariable<int>(-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;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 장착된 무기(메인+보조)의 특성 합산
|
||||
/// </summary>
|
||||
public WeaponTrait EquippedWeaponTraits
|
||||
{
|
||||
get
|
||||
{
|
||||
WeaponTrait traits = currentWeapon != null ? currentWeapon.WeaponTrait : WeaponTrait.None;
|
||||
if (currentOffhandWeapon != null)
|
||||
traits |= currentOffhandWeapon.WeaponTrait;
|
||||
return traits;
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트
|
||||
public event Action<WeaponData> OnWeaponEquipped;
|
||||
public event Action<WeaponData> OnWeaponUnequipped;
|
||||
public event Action<WeaponData> OnOffhandWeaponEquipped;
|
||||
public event Action<WeaponData> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보조손 무기 장착 (서버에서만 호출)
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보조손 무기 해제 (서버에서만 호출)
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보조손 내부 해제 로직 (클라이언트 동기화용)
|
||||
/// </summary>
|
||||
private void UnequipOffhandWeaponInternal()
|
||||
{
|
||||
if (currentOffhandWeaponInstance == null && currentOffhandWeapon == null) return;
|
||||
|
||||
WeaponData previousWeapon = currentOffhandWeapon;
|
||||
DespawnOffhandVisualsLocal();
|
||||
|
||||
OnOffhandWeaponUnequipped?.Invoke(previousWeapon);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보조손 무기의 스탯 보너스 적용
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보조손 무기의 스탯 보너스 제거
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보조손 무기 외형 생성 및 부착 (서버). 항상 왼손에 장착합니다.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 클라이언트: registeredOffhands 인덱스로 보조손 무기 외형 생성
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보조손 무기 외형 제거 (서버)
|
||||
/// </summary>
|
||||
private void DespawnOffhandVisuals()
|
||||
{
|
||||
if (currentOffhandWeaponInstance == null) return;
|
||||
Destroy(currentOffhandWeaponInstance);
|
||||
currentOffhandWeaponInstance = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보조손 무기 외형 제거 (클라이언트)
|
||||
/// </summary>
|
||||
private void DespawnOffhandVisualsLocal()
|
||||
{
|
||||
if (currentOffhandWeaponInstance == null) return;
|
||||
Destroy(currentOffhandWeaponInstance);
|
||||
currentOffhandWeaponInstance = null;
|
||||
currentOffhandWeapon = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// 내부 해제 로직 (클라이언트 동기화용)
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user