feat: 무기 장착 시스템 핵심 구현
- WeaponData: 무기 스탯 보너스, 배율, 슬롯, 프리팹 정보를 담은 ScriptableObject - WeaponEquipment: 무기 장착/해제, 스탯 보너스 적용, 메시 이름 기반 소켓 검색, 스케일 보정 - WeaponSlot enum: RightHand, LeftHand, Back, Hip, TwoHanded 슬롯 지원 - GetInstanceID() 음수 버그 수정 (>= 0 → == -1 체크) - 소켓 스케일 보정으로 0.01 스케일 메시에서도 무기가 올바른 크기로 표시됨 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
8
Assets/Scripts/Weapons.meta
Normal file
8
Assets/Scripts/Weapons.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e113408f0cfce6c4aa60725c3005f25c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
107
Assets/Scripts/Weapons/WeaponData.cs
Normal file
107
Assets/Scripts/Weapons/WeaponData.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.Stats;
|
||||
|
||||
namespace Colosseum.Weapons
|
||||
{
|
||||
/// <summary>
|
||||
/// 무기 장착 위치
|
||||
/// </summary>
|
||||
public enum WeaponSlot
|
||||
{
|
||||
RightHand, // 오른손
|
||||
LeftHand, // 왼손
|
||||
Back, // 등
|
||||
Hip, // 허리
|
||||
TwoHanded, // 양손
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 무기 데이터. 무기의 기본 정보, 스탯 보너스, 배율, 외형을 관리합니다.
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// 스탯 타입에 해당하는 보너스 값 반환
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
401
Assets/Scripts/Weapons/WeaponEquipment.cs
Normal file
401
Assets/Scripts/Weapons/WeaponEquipment.cs
Normal file
@@ -0,0 +1,401 @@
|
||||
using System;
|
||||
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
|
||||
using Colosseum.Stats;
|
||||
|
||||
namespace Colosseum.Weapons
|
||||
{
|
||||
/// <summary>
|
||||
/// 무기 장착을 관리하는 컴포넌트.
|
||||
/// 무기 장착 시 스탯 보너스를 적용하고 배율을 제공하며, 무기 외형을 표시합니다.
|
||||
/// 메시 이름으로 소켓을 자동 검색합니다.
|
||||
/// </summary>
|
||||
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<StatType, StatModifier> activeModifiers
|
||||
= new System.Collections.Generic.Dictionary<StatType, StatModifier>();
|
||||
|
||||
// 무기 장착 상태 동기화
|
||||
private NetworkVariable<int> equippedWeaponId = new NetworkVariable<int>(-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<WeaponData> OnWeaponEquipped;
|
||||
public event Action<WeaponData> OnWeaponUnequipped;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// CharacterStats 참조 확인
|
||||
if (characterStats == null)
|
||||
{
|
||||
characterStats = GetComponent<CharacterStats>();
|
||||
}
|
||||
|
||||
// 소켓 자동 검색
|
||||
CacheSockets();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
// 네트워크 변수 변경 콜백
|
||||
equippedWeaponId.OnValueChanged += HandleEquippedWeaponChanged;
|
||||
|
||||
// 서버에서 시작 무기 장착
|
||||
if (IsServer && startingWeapon != null)
|
||||
{
|
||||
EquipWeapon(startingWeapon);
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
equippedWeaponId.OnValueChanged -= HandleEquippedWeaponChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 메시 이름으로 소켓 Transform 캐싱
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이름으로 자식 Transform 재귀 검색
|
||||
/// </summary>
|
||||
private Transform FindDeepChild(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
|
||||
// BFS로 검색
|
||||
var queue = new System.Collections.Generic.Queue<Transform>();
|
||||
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 조회
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 무기 장착 (서버에서만 호출)
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 무기 해제 (서버에서만 호출)
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 내부 해제 로직 (클라이언트 동기화용)
|
||||
/// </summary>
|
||||
private void UnequipWeaponInternal()
|
||||
{
|
||||
if (currentWeapon == null) return;
|
||||
|
||||
WeaponData previousWeapon = currentWeapon;
|
||||
RemoveStatBonuses();
|
||||
DespawnWeaponVisuals();
|
||||
currentWeapon = null;
|
||||
|
||||
OnWeaponUnequipped?.Invoke(previousWeapon);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 무기의 스탯 보너스 적용
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 무기의 스탯 보너스 제거
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 무기 외형 생성 및 부착
|
||||
/// </summary>
|
||||
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<NetworkObject>(out var networkObject))
|
||||
{
|
||||
networkObject.Spawn(true);
|
||||
}
|
||||
|
||||
Debug.Log($"[WeaponEquipment] Spawned weapon visual: {weapon.WeaponName}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 무기 외형 제거
|
||||
/// </summary>
|
||||
private void DespawnWeaponVisuals()
|
||||
{
|
||||
if (currentWeaponInstance == null) return;
|
||||
|
||||
// 네트워크 Object면 Despawn
|
||||
if (currentWeaponInstance.TryGetComponent<NetworkObject>(out var networkObject) && networkObject.IsSpawned)
|
||||
{
|
||||
if (IsServer)
|
||||
{
|
||||
networkObject.Despawn(true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(currentWeaponInstance);
|
||||
}
|
||||
|
||||
currentWeaponInstance = null;
|
||||
Debug.Log("[WeaponEquipment] Despawned weapon visual");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 슬롯 타입에 맞는 소켓 Transform 반환
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서버에 무기 장착 요청
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
public void RequestEquipWeaponRpc(int weaponInstanceId)
|
||||
{
|
||||
// TODO: WeaponDatabase에서 ID로 WeaponData 조회 후 EquipWeapon 호출
|
||||
Debug.Log($"[WeaponEquipment] Client requested weapon equip: {weaponInstanceId}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서버에 무기 해제 요청
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
public void RequestUnequipWeaponRpc()
|
||||
{
|
||||
UnequipWeapon();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user