메이플스토리월드 :: 인벤토리 구현 13

chooha·2026년 9월 2일

메이플스토리월드

목록 보기
13/15

📝 개발일지 - 카테고리 필터 구현 + 드롭 기능 트러블슈팅

👨‍💻 오늘의 개발 작업

카테고리 필터를 카테고리별로 InventoryContainer를 별도 소유하는 방식으로 구현했음. 서버 구조 자체는 이미 검증된 패턴(스냅샷 diff, 이벤트 기반 브로드캐스트)을 카테고리 단위로 나누기만 하면 돼서 설계는 금방 끝났고, 드래그 고스트 잔상 두 종류, 숨겨진 슬롯의 유령 터치 이벤트 문제까지 잡아서 스왑(드래그앤드롭) 기능을 카테고리 구조 위에서 다시 안정화했음. 여기에 아이템 추가 팝업, 카테고리 탭 하이라이트까지 마무리.


💡 오늘의 기록

1. 카테고리 "완전 분리 방식" — 서버 구조

PlayerInventory가 카테고리별 InventoryContainer를 각각 소유하도록 구조 변경. InventoryContainer(StructType) 자체는 카테고리 개념을 몰라도 되게 애초에 설계해뒀어서(DIP, maxStack 외부 주입) 수정 없이 그대로 재사용됨.

프로퍼티 설계는 한 번 갈아엎음: 처음엔 equipContainer/consumeContainer/... 카테고리당 프로퍼티를 각각 5개씩(컨테이너용, 용량용) 뒀는데, 이러면 카테고리 문자열 → 프로퍼티 라우팅용 map을 GetContainer/GetCapacity가 호출될 때마다 새로 만들어야 했음. 다시 보니 굳이 그럴 이유가 없어서 containers/capacities table 프로퍼티 하나씩으로 정리 — 인스펙터에서 카테고리별로 개별 확인은 안 되지만, 라우팅 메서드가 한 줄로 줄고 카테고리가 늘어도 코드 수정이 필요 없어짐.

-- GetContainer/GetCapacity 최종 형태
method InventoryContainer GetContainer(string category)
local container = self.containers[category]
if container == nil then
	log_error("[PlayerInventory] GetContainer: 알 수 없는 카테고리 " .. tostring(category))
end
return container
end

이벤트 재설계: OnInventoryModified엔 category 필드 추가 — 유저 액션 하나는 항상 카테고리 하나만 건드리므로 "카테고리당 이벤트 하나"는 자연스러운 결과. OnInventoryInitialized(로그인 시 전체 스냅샷)는 처음엔 이것도 카테고리별로 5번 나눠 보내려다가, 5개 컨테이너가 애초에 동시에(하나의 저장 데이터에서) 준비되는데 굳이 나눠 보낼 이유가 없다는 걸 깨닫고 하나의 이벤트로 합침. 다만 중첩 테이블({category: {slotIdx: slot}})로 합치려던 1차안은 MSW 공식 튜토리얼 예제가 전부 1차원 데이터만 다루는 걸 확인하고 폐기 — 대신 카테고리별 프로퍼티 5개(equipSlots~cashSlots)를 한 이벤트에 나란히 실어서, "이벤트 하나 = 전체 원자적 전달"이라는 목표는 유지하면서 검증 안 된 구조는 피함.

저장 포맷도 {slots:[...]} → {categories:{["소비"]=[...], ...}}로 변경. 모르는 카테고리 키는 조용히 스킵(구버전 세이브 방어).

⚠️ 코드 다시 훑다가 잡은 버그 2개: RestoreFromSaveData의 파싱 실패 체크가 옛 필드(data.slots) 그대로 남아있어서 저장 데이터 복원이 항상 실패하던 것, capacities 기본값이 빈 테이블이라 모든 카테고리 용량이 0이 되던 것. 둘 다 실행 전에 코드만 봐도 걸리는 버그였는데, 리팩터링 도중엔 놓치기 쉬웠음.

2. 카테고리 문자열 전역화 — InventoryConstants

"장비"/"소비"/"기타"/"설치"/"캐시" 5개 문자열을 스크립트마다 로컬 상수로 반복 선언하다가(mLua는 최상단 local을 메서드 간에도 공유 안 함), 서버(PlayerInventory)뿐 아니라 클라이언트(UIInventory)도 같은 문자열을 참조해야 하는 시점이 오면서 Logic으로 전역화함(_ItemData와 동일 패턴).

이름은 프로퍼티(property string EQUIP = "장비" 등)로 선언하되, 일부러 UPPER_SNAKE_CASE로 지음 — 컨벤션표 기준으로는 프로퍼티가 원래 camelCase지만, 이 값들은 디자이너가 튜닝할 값이 아니라 "코드가 참조하는 게임 규칙 정의"라 상수 표기가 더 정확한 신호를 줌. 인스펙터에도 노출 안 함(표시 안 함).

3. 드래그 고스트 잔상 — 두 종류를 따로 잡음

① 위치 잔상: 아이템을 집을 때 고스트가 "이전 위치"에서 한 틱 보였다가 정상 위치로 튀는 현상. Show()가 Visible=true를 반영하는 시점이 SetScreenPos()의 위치 반영(레이아웃 갱신)보다 먼저 화면에 나타나는 게 원인으로 보였음. 처음엔 _TimerService:SetTimer(self, fn, 0, false, 0)로 한 틱 늦춰서 우회했다가, 최종적으로는 Show() 내부에서 커서 위치 계산 → SetScreenPos → 이미지 세팅 → Visible=true 순서를 전부 통합해서 호출부(UIInventory)가 순서를 신경 쓸 필요 없게 정리함.

② 아이콘 잔상: 다른 아이템을 연달아 집을 때 이전 아이템 아이콘이 아주 짧게 겹쳐 보이는 현상. Enable만 껐다 켜는 방식으론 ImageRUID가 그대로 남아있어서, 리소스 교체가 반영되는 타이밍에 이전 이미지가 살짝 노출되는 게 원인. Hide()/SetEmpty() 양쪽 다 Enable=false 하기 전에 ImageRUID=""로 리소스 자체를 비우는 패턴으로 통일(UIDragGhost, UIItemSlot 둘 다 적용).

패턴 정리: 리소스 ID(ImageRUID 등)를 교체할 땐 Enable만 토글하지 말고, 끌 때는 리소스 자체를 비우고, 켤 때는 리소스를 먼저 확정한 뒤 Enable=true를 켜는 순서로 통일.

③ (별개 방어 코드) 숨겨진 슬롯의 잔여 데이터: 위 잔상 버그의 원인을 찾던 중 "숨긴 슬롯이 터치 이벤트를 계속 받아서 그런가"라는 가설도 세웠었는데(SetVisible(false)는 렌더링만 끄지 UITouchReceiveComponent의 터치 수신까지는 안 끔), 디버그 로그(TouchEnter slotIdx=12 itemId=0)로 확인해보니 실제 원인은 아니어서 기각함. 다만 "숨겨진 엔티티도 터치는 계속 받는다"는 사실 자체는 실제라 별도로 방어해둘 가치가 있어서, RenderCurrentCategory에서 숨기는 슬롯도 RefreshSlot(slotIdx, 0, 0)으로 캐시를 같이 비우는 코드는 남겨둠(예: 숨겨진 슬롯에서 툴팁이 열리는 것 방지).

4. 아이템 추가 팝업 + 카테고리 탭 하이라이트

팝업: 새로 만들지 않고 이미 있던 UIToast(_UIToast:ShowMessage(message, userId))를 재사용. container:AddItem이 반환하는 InventoryUpdateResult(success, amount)를 활용해 완전 성공/부분 성공(공간 부족)/완전 실패 세 갈래 메시지 분기.

탭 하이라이트: 이전에 썼던 UIItemSlot.SetHighlight와 같은 패턴(SpriteGUIRendererComponent.Color 틴트)을 탭 버튼에도 적용. 탭 엔티티를 OnBeginPlay에서 연결할 때 self._T.categoryTabEntities에 카테고리별로 캐싱해두고, 탭 전환/최초 접속 시 UpdateCategoryTabHighlight()로 일괄 갱신.

5. 그 외 코드 정리한 것들

  • 죽은 코드 제거: "슬롯 선택 + 삭제 버튼" 방식은 드래그/드롭 완성으로 대체됐는데 정리가 안 돼있었음(selectedSlotIdx/SelectSlot이 호출되는 곳 자체가 없었음) — 삭제는 나중에 "인벤토리 밖으로 드롭하면 버려짐" 방식으로 재구현 예정이라 일단 통째로 제거.
  • 디버깅 과정에서 남긴 주석 처리된 실험 코드(SetTimer 우회, 초기 SetScreenPos 순서 실험) 정리.
  • UIDragGhost.Hide/SetScreenPos에 @ExecSpace("ClientOnly") 명시 누락된 것 — 다른 메서드와 일관성 맞춤.
  • Color(167/255, 167/255, 167/255, 1.0) 형태로 헥스 색상(#A7A7A7)을 표현 — Color는 0~255가 아니라 0~1 float를 받음.

📚 개발 참고

0개의 댓글