카테고리 필터를 카테고리별로 InventoryContainer를 별도 소유하는 방식으로 구현했음. 서버 구조 자체는 이미 검증된 패턴(스냅샷 diff, 이벤트 기반 브로드캐스트)을 카테고리 단위로 나누기만 하면 돼서 설계는 금방 끝났고, 드래그 고스트 잔상 두 종류, 숨겨진 슬롯의 유령 터치 이벤트 문제까지 잡아서 스왑(드래그앤드롭) 기능을 카테고리 구조 위에서 다시 안정화했음. 여기에 아이템 추가 팝업, 카테고리 탭 하이라이트까지 마무리.
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이 되던 것. 둘 다 실행 전에 코드만 봐도 걸리는 버그였는데, 리팩터링 도중엔 놓치기 쉬웠음.
InventoryConstants"장비"/"소비"/"기타"/"설치"/"캐시" 5개 문자열을 스크립트마다 로컬 상수로 반복 선언하다가(mLua는 최상단 local을 메서드 간에도 공유 안 함), 서버(PlayerInventory)뿐 아니라 클라이언트(UIInventory)도 같은 문자열을 참조해야 하는 시점이 오면서 Logic으로 전역화함(_ItemData와 동일 패턴).
이름은 프로퍼티(property string EQUIP = "장비" 등)로 선언하되, 일부러 UPPER_SNAKE_CASE로 지음 — 컨벤션표 기준으로는 프로퍼티가 원래 camelCase지만, 이 값들은 디자이너가 튜닝할 값이 아니라 "코드가 참조하는 게임 규칙 정의"라 상수 표기가 더 정확한 신호를 줌. 인스펙터에도 노출 안 함(표시 안 함).
① 위치 잔상: 아이템을 집을 때 고스트가 "이전 위치"에서 한 틱 보였다가 정상 위치로 튀는 현상. 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)으로 캐시를 같이 비우는 코드는 남겨둠(예: 숨겨진 슬롯에서 툴팁이 열리는 것 방지).
팝업: 새로 만들지 않고 이미 있던 UIToast(_UIToast:ShowMessage(message, userId))를 재사용. container:AddItem이 반환하는 InventoryUpdateResult(success, amount)를 활용해 완전 성공/부분 성공(공간 부족)/완전 실패 세 갈래 메시지 분기.
탭 하이라이트: 이전에 썼던 UIItemSlot.SetHighlight와 같은 패턴(SpriteGUIRendererComponent.Color 틴트)을 탭 버튼에도 적용. 탭 엔티티를 OnBeginPlay에서 연결할 때 self._T.categoryTabEntities에 카테고리별로 캐싱해두고, 탭 전환/최초 접속 시 UpdateCategoryTabHighlight()로 일괄 갱신.
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를 받음.InputService — MapleStory Worlds Creator Center
ConnectEvent/DisconnectEvent의 정확한 반환 타입(EventHandlerBase)을 확인한 문서. MouseMoveEvent 리스너 해제 코드를 정상 동작하게 고치는 근거가 됨.
인벤토리 스크립트 작성하기 — MapleStory Worlds Creator Center
OnInventoryInitialized 이벤트로 인벤토리 데이터를 통째로 전달하는 공식 예제. SyncTable<V>가 1차원 인덱스 기반 컬렉션이라는 걸 확인하고, 중첩 테이블 대신 카테고리별 명시적 프로퍼티로 이벤트를 설계하는 근거가 됨.