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:
2026-04-01 21:13:05 +09:00
parent 2ec7216b6d
commit 3663692b9d
6 changed files with 319 additions and 2 deletions

View File

@@ -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}

View File

@@ -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" :

View File

@@ -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>

View File

@@ -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)
{

View File

@@ -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;

View File

@@ -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>