플레이어 손 소켓 기반 무기 프리팹 장착 시스템
목차
1. 시스템 요구 사항
무기를 장착하면 플레이어의 손 소켓에 해당 무기 프리팹이 생성되어야 한다.
각 무기는 서로 다른 위치/회전 보정값을 가져야 하며, 장착 시 해당 보정이 적용되어야 한다.
같은 무기를 다시 장착하면 중복 생성이 발생하지 않아야 한다.
기존 무기가 존재하면 새 무기 장착 전 반드시 제거되어야 한다.
2. 설계 목표
- ItemId 기반 매핑 구조
- Dictionary를 통한 빠른 검색
- 소켓 기준 로컬 변환 보정
- 장착과 해제를 명확히 분리
3. 흐름도
3.1. 장착
장착 요청(EquipByItemId)
↓
동일 무기인지 확인(같으면 무시)
↓
기존 무기 해제(Unequip)
↓
itemId=0이면 미장착 상태로 종료
↓
Dictionary(itemId→WeaponSpec) 조회
↓
프리팹 Instantiate(부모=handSocket, worldPositionStays=false)
↓
로컬 위치/회전/스케일 보정 적용
↓
현재 장착 상태(curWeaponItemId, currentWeaponGO) 갱신
3.2. 해제
해제 요청(Unequip)
↓
currentWeaponGO Destroy
↓
currentWeaponGO=null, curWeaponItemId=0
여기서 핵심은 장착 요청이 들어온 순간에만 '생성/파기' 가 발생한다는 점이다.
Update 루프가 관여하지 않으므로 장착 상태의 변경 지점이 코드 구조상 명확하게 고정된다.
4. 구현
4.1. 무기 프리셋 데이터 구조
[System.Serializable]
public class WeaponSpec
{
public string name;
public int itemId;
public GameObject prefab;
public Vector3 localPosition;
public Vector3 localEulerAngles;
public Vector3 localScale = Vector3.one;
}WeaponSpec은 어떤 아이템 ID가 어떤 프리팹을 어떤 보정값으로 손에 붙일지를 정의하는 데이터 컨테이너다.
System.Serializable을 붙인 이유는 Unity 인스펙터에서 이 클래스를 직렬화하여 편집 가능하게 만들기 위해서다.
코드에 하드코딩된 테이블을 두면 수정 시마다 코드 변경과 빌드가 필요하지만, 인스펙터 기반 프리셋은 아트 리소스가 바뀌거나 보정값이 달라질 때 빠르게 튜닝할 수 있다는 장점이 있다.
대신 잘못된 값이 들어가도 컴파일 단계에서 잡히지 않는다는 단점이 있으므로, 런타임에서 null 검증과 경고 로그로 방어하는 구조가 필요해진다.
localPosition/localEulerAngles/localScale을 따로 둔 이유는 프리팹별 원점과 축이 다르기 때문이다.
동일한 소켓에 자식으로 붙여도 모델마다 손에 쥐는 위치가 다르므로, 보정값을 데이터로 분리해 두면 프리팹 교체나 모델 수정에도 장착 로직은 그대로 유지된다.
4.2. 매핑 구성 및 소켓 탐색
public List<WeaponSpec> weapons = new List<WeaponSpec>();
private Dictionary<int, WeaponSpec> _map;
private void Awake()
{
instance = this;
_map = new Dictionary<int, WeaponSpec>(weapons.Count);
foreach (var spec in weapons)
{
if (spec != null && spec.prefab != null)
_map[spec.itemId] = spec;
}
if (handSocket == null)
{
var t = transform.Find("Armature/Hand.R") ?? transform.Find("Armature/RightHand") ?? transform;
handSocket = t;
}
}Awake에서 매핑을 만드는 이유는 장착 요청 시점에 검색 비용과 예외 가능성을 최소화하기 위함이다.
무기를 장착하는 순간에 리스트를 순회하며 찾는 방식은 구현은 단순하지만, 요청이 늘어날수록 비용이 누적되고 또한 누락된 프리셋이 있을 때 대응이 늦어진다.
반면 Dictionary로 itemId→WeaponSpec을 미리 구축하면 장착 시점에는 TryGetValue 한 번으로 끝나며, 장착 로직이 항상 동일한 비용으로 실행된다.
Dictionary를 사용할 때의 단점은 키 중복이 있을 경우 마지막 값으로 덮어쓰게 된다는 점이다.
이 코드는 _map[spec.itemId] = spec; 형태이므로 동일 itemId가 여러 개 있으면 마지막 입력이 유효해진다.
포트폴리오 문서에서는 중복 itemId는 데이터 에러이며, 제작 과정에서는 프리셋 구성 단계에서 제거한다는 식으로 데이터 정책을 명시하거나, 중복 발견 시 Debug.LogWarning을 추가하는 개선 포인트를 남기면 완성도가 올라간다.
handSocket 자동 탐색은 인스펙터에 소켓을 연결하지 못했을 때의 안전장치다.
transform.Find는 경로 기반 탐색이며 편리하지만 문자열 의존성이 있고, 계층 구조가 바뀌면 실패한다는 단점이 있다.
다만 이 탐색은 Awake에서 1회 수행되므로 성능 부담은 제한적이다.
4.3. 무기 오브젝트 장착 단일 진입점
ublic void EquipByItemId(int itemId)
{
if (curWeaponItemId == itemId) return;
Unequip();
if (itemId == 0) return;
if (!_map.TryGetValue(itemId, out var spec) || spec.prefab == null)
{
Debug.LogWarning($"[PlayerWeapon] Unknown itemId {itemId}");
curWeaponItemId = 0;
return;
}
currentWeaponGO = Instantiate(spec.prefab, handSocket, worldPositionStays: false);
currentWeaponGO.name = $"Weapon_{spec.name}";
currentWeaponGO.SetActive(true);
currentWeaponGO.transform.localPosition = spec.localPosition;
currentWeaponGO.transform.localEulerAngles = spec.localEulerAngles;
currentWeaponGO.transform.localScale = spec.localScale;
curWeaponItemId = itemId;
}EquipByItemId는 월드 장착의 단일 진입점이다.
UI나 인벤토리 쪽에서 무기 프리팹을 직접 Instantiate하지 않고 ID를 전달해 장착을 요청하는 구조를 완성하는 함수다.
장착 요청이 들어왔을 때 가장 먼저 동일 무기인지 확인하고 바로 return하는 이유는 중복 인스턴스 생성을 원천 차단하기 위해서다.
토글 해제를 여기서 처리하지 않고, 해제는 명시적 Unequip 호출로만 수행되게 만든 것은 입력 경로를 분리해 설계를 단순하게 유지하기 위함이다.
장착 전 Unequip을 호출하는 구조는 장착 교체를 항상 초기화 후 재구성으로 통일한다.
이렇게 하면 장착이 여러 번 반복되더라도 기존 무기가 남아있을 수 있는 경로가 사라진다.
장착 시스템에서 가장 위험한 버그는 현재 인스턴스와 상태 변수(curWeaponItemId)가 어긋나는 것이므로, 이 코드는 그 가능성을 구조적으로 줄인다.
itemId가 0이면 미장착으로 취급한다.
이는 호출 측에서 무기 없음을 표현하는 규약이며, 별도의 불리언이나 null을 추가하지 않고 정수 규약으로 통일함으로써 장착 상태 표현을 단순화한다.
다만 이 규약은 프로젝트 전반에서 동일하게 지켜져야 하며, 그렇지 않으면 장착 상태가 분산될 수 있으므로 문서에 명확히 적어두는 것이 좋다.
TryGetValue는 Dictionary에서 키를 찾는 표준 방식이며, 실패 시 예외가 아니라 false를 반환한다.
이 방식은 unknown itemId가 들어왔을 때 크래시 대신 경고 로그로 수렴하도록 만들어 안정성이 높다.
장점은 런타임 안정성이고, 단점은 데이터가 잘못된 상태에서도 게임이 계속 진행될 수 있다는 점이다.
그래서 Debug.LogWarning을 남겨 디버깅 경로를 확보한다.
Instantiate(spec.prefab, handSocket, worldPositionStays:false)는 생성과 동시에 부모를 handSocket으로 지정한다.
worldPositionStays를 false로 둔 이유는 로컬 기준으로 붙인 뒤 localPosition/localEulerAngles/localScale을 바로 적용하기 위해서다.
true로 두면 월드 좌표를 유지하려고 하면서 로컬 좌표가 꼬일 수 있고, 소켓 기준 보정이라는 설계 목표와 충돌한다.
마지막으로 로컬 보정을 적용한다.
여기서 localEulerAngles를 쓰는 것은 인스펙터 친화성이 높기 때문이다.
Quaternion을 직접 다루면 회전 표현이 직관적이지 않지만, Euler 각은 튜닝이 쉽다.
단점은 짐벌락 같은 고전적인 이슈가 있을 수 있다는 점이지만, 이 케이스는 고정 보정값을 설정하는 목적이라 실질적 문제는 거의 발생하지 않는다.
ㅇ
4.4. 라이프사이클 종료와 상태 초기화
public void Unequip()
{
if (currentWeaponGO != null)
{
Destroy(currentWeaponGO);
currentWeaponGO = null;
}
curWeaponItemId = 0;
}Unequip은 해제의 단일 진입점이다.
currentWeaponGO가 null이 아닐 때 Destroy로 제거하고 참조를 즉시 null로 만든다.
Destroy는 Unity의 오브젝트 파기 API이며 실제 제거는 프레임 종료 시점에 일어나지만, 참조를 null로 만드는 순간부터 시스템은 무기가 없다고 판단할 수 있다.
이 구조는 이미 해제된 무기를 또 해제해도 안전하게 동작하는 멱등성을 가진다.
curWeaponItemId를 0으로 돌려 미장착 상태로 초기화하는 것은 장착 상태 표현 규약을 유지하기 위한 필수 단계다.
해제 후에도 ID가 남아 있으면 EquipByItemId에서 동일 무기 판단이 잘못 작동할 수 있으므로, 상태 변수 초기화는 반드시 해제 로직의 일부로 포함되어야 한다.
4.5. 씬 전환/리셋 대응을 위한 강제 동기화
public void ResetTo(int itemId)
{
Unequip();
if (itemId != 0) EquipByItemId(itemId);
}ResetTo는 상태를 강제 동기화하기 위한 유틸성 인터페이스다.
씬 전환, 캐릭터 리셋, 저장 데이터 로드처럼 현재 손에 든 오브젝트가 깨질 수 있는 이벤트 이후에 안전하게 다시 구성할 수 있도록 만든다.
Unequip 후 EquipByItemId로 다시 장착하는 구조를 선택한 이유는 장착의 단일 진입점 정책을 유지하기 위해서다.
즉 어떤 상황에서든 손에 무기를 붙이는 방법은 EquipByItemId뿐이라는 규칙이 유지된다.
HasWeapon은 외부 시스템이 간단히 장착 여부를 묻는 용도다.
curWeaponItemId가 곧 상태의 단일 진실 원천이므로, 별도 bool을 만들지 않고 이 값을 이용한다.
4.6. 무기 장착 여부 확인
public bool HasWeapon() => curWeaponItemId != 0;HasWeapon 함수는 현재 장착한 아이템이 있는지 확인하는 함수이다.
만약 curWeapinItemId가 0이 아니라면, true를 반환하여, 장착한 아이템이 있다고 알린다.
0이라면, false를 반환하여, 장착한 아이템이 없음을 알린다.
5. 개발 의도
이 시스템은 UI가 장착을 결정하고, 월드는 시각적 장착을 구현한다는 계층 분리를 끝까지 유지하는 것을 목표로 했다.
UI에서 ItemId만 넘기면 월드는 프리셋과 매핑을 통해 즉시 장착을 수행한다.
프리팹 생성과 보정은 EquipWeaponAtHand에서만 일어나며, 그 결과 상태는 curWeaponItemId와 currentWeaponGO로만 관리된다.
이 구조는 상태의 분산을 막고 디버깅을 단순화한다.
또한 Update 기반으로 매 프레임 장착 상태를 감시하지 않고, 장착 요청이 들어오는 순간에만 인스턴스를 생성/파기하도록 설계함으로써 상태 변경 지점을 코드 구조상 고정시켰다.
결과적으로 장착 시스템은 '요청 → 검증 → 해제 → 매핑 조회 → 생성 → 보정 → 상태 갱신'이라는 결정론적 흐름을 갖고, 예기치 않은 중복 생성이나 상태 오염을 구조적으로 차단한다.
