로블록스의 서바이벌 샌드박스, RPG, 배틀로얄 게임에서 인벤토리와 핫바는 플레이어가 가장 빈번하게 조작하는 핵심 인터페이스입니다. 로블록스가 기본 제공하는 백팩(Backpack)은 디자인이 구식이고, 아이템 나누기나 슬롯 정렬을 지원하지 않으며, 모바일 환경에서 오작동이 자주 발생합니다. 이를 대체하는 커스텀 드래그 앤 드롭 시스템은 상용 게임 제작의 필수 요건입니다.
본 시스템 엔지니어링 가이드에서는 크로스 플랫폼 인벤토리 컨트롤러를 설계합니다. InputObject 포인터 이벤트 수신, 드래그 도중 마우스를 따라다니는 고스트 아이콘 연출, 슬롯 충돌 판정, 서버 권한 슬롯 교체 검증, 키보드 숫자키(1~9) 및 모바일 터치 제스처 연동을 상세히 다룹니다.
1. 드래그 앤 드롭의 메커니즘: 포인터 이벤트와 드래그 임계값
신뢰할 수 있는 드래그 앤 드롭 UI를 구축하려면 단순 클릭과 의도적인 드래그를 명확히 구분해야 합니다:
- 드래그 히스테리시스 임계값: 터치 또는 클릭 시작 지점에서 최소 6~8픽셀 이상 포인터가 이동했을 때만 드래그 모드를 활성화하여 오작동을 방지합니다.
- InputObject 수명 주기: InputBegan, InputChanged, InputEnded 이벤트를 조합하여 마우스 좌표와 모바일 터치 좌표를 일관되게 추적합니다.
- 플로팅 고스트 아이콘: 드래그 시작 시 원본 아이콘을 복제하여 약간 확대된(1.1배) 크기와 그림자 효과를 부여하고 커서 위치를 따라다니도록 배치합니다.
- 비차단 레이캐스트 피킹: 떠 있는 고스트 아이콘의 Active 속성을 false로 설정하여 아래쪽에 위치한 슬롯 프레임이 마우스 진입 신호를 정상 수신하도록 합니다.
2. 상용급 클라이언트 드래그 앤 드롭 컨트롤러 구현 코드
아래는 부드러운 고스트 연출, 슬롯 충돌 판정, 서버 슬롯 스왑 요청을 구현한 클라이언트 측 Luau 모듈입니다:
- ZIndex 최상위 격상: 드래그 중인 고스트 아이템이 다른 모든 모달 창 및 UI 요소보다 항상 위에 렌더링되도록 ZIndex를 제어합니다.
- 슬롯 히트 테스팅: 등록된 인벤토리 슬롯들의 AbsolutePosition 및 AbsoluteSize 좌표를 순회 검사하여 드롭 대상 슬롯을 정확히 탐색합니다.
- 취소 시 복귀 애니메이션: 빈 화면에 잘못 드롭했을 경우 TweenService를 사용하여 원본 슬롯 위치로 부드럽게 되돌아간 후 소멸합니다.
--!strict
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local Players = game:GetService("Players")
local DragController = {}
DragController.__index = DragController
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui") :: PlayerGui
export type SlotData = {
Frame: Frame,
SlotIndex: number,
ItemId: string?,
Count: number
}
local registeredSlots: { [Frame]: SlotData } = {}
local activeDrag: {
SourceSlot: SlotData,
Ghost: ImageLabel,
StartPos: Vector2,
IsDragging: boolean
}? = nil
function DragController.RegisterSlot(frame: Frame, slotIndex: number, itemId: string?, count: number)
registeredSlots[frame] = {
Frame = frame,
SlotIndex = slotIndex,
ItemId = itemId,
Count = count
}
frame.InputBegan:Connect(function(input: InputObject)
if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then
DragController.StartDrag(frame, input.Position)
end
end)
end
function DragController.StartDrag(slotFrame: Frame, inputPos: Vector3)
local data = registeredSlots[slotFrame]
if not data or not data.ItemId then return end
local icon = slotFrame:FindFirstChild("ItemIcon") :: ImageLabel?
if not icon then return end
local ghost = icon:Clone()
ghost.Size = UDim2.new(0, icon.AbsoluteSize.X * 1.1, 0, icon.AbsoluteSize.Y * 1.1)
ghost.Position = UDim2.new(0, inputPos.X, 0, inputPos.Y)
ghost.AnchorPoint = Vector2.new(0.5, 0.5)
ghost.ZIndex = 100
ghost.Active = false
ghost.Parent = playerGui:FindFirstChildOfClass("ScreenGui")
activeDrag = {
SourceSlot = data,
Ghost = ghost,
StartPos = Vector2.new(inputPos.X, inputPos.Y),
IsDragging = true
}
end
UserInputService.InputChanged:Connect(function(input: InputObject)
if activeDrag and activeDrag.IsDragging then
if input.UserInputType == Enum.UserInputType.MouseMovement or input.UserInputType == Enum.UserInputType.Touch then
activeDrag.Ghost.Position = UDim2.new(0, input.Position.X, 0, input.Position.Y)
end
end
end)
UserInputService.InputEnded:Connect(function(input: InputObject)
if not activeDrag or not activeDrag.IsDragging then return end
if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then
local dropPos = Vector2.new(input.Position.X, input.Position.Y)
local targetSlot: SlotData? = nil
for frame, slotData in pairs(registeredSlots) do
local pos = frame.AbsolutePosition
local size = frame.AbsoluteSize
if dropPos.X >= pos.X and dropPos.X <= (pos.X + size.X) and
dropPos.Y >= pos.Y and dropPos.Y <= (pos.Y + size.Y) then
targetSlot = slotData
break
end
end
if targetSlot and targetSlot ~= activeDrag.SourceSlot then
-- 서버로 슬롯 스왑 RemoteEvent 호출
-- InventoryRemote:FireServer("SwapSlots", activeDrag.SourceSlot.SlotIndex, targetSlot.SlotIndex)
end
activeDrag.Ghost:Destroy()
activeDrag = nil
end
end)
return DragController
3. 핫바 숫자키(1~9) 바인딩 및 인체공학
핫바는 플레이어의 손발과 같습니다. 긴박한 전투에서 마우스 조작 없이 즉각 원하는 도구를 꺼내는 단축키 체계가 필수적입니다:
- 직접 키코드 바인딩: ContextActionService를 사용하여 1번부터 9번까지의 숫자 키를 등록하고 도구 장착을 즉시 실행합니다.
- 마우스 휠 스크롤 순회: 마우스 휠(MouseWheel) 이벤트를 수신하여 핫바 슬롯을 좌우로 순차 이동시키는 편의성을 지원합니다.
- 선택 슬롯 하이라이트 인디케이터: 현재 장착된 슬롯 둘레에 네온 글로우 테두리(UIStroke)를 렌더링하고 스프링 애니메이션으로 부드럽게 이동시킵니다.
- 기본 툴 인스턴스 분리: 로블록스의 기본 Tool 인스턴스에 의존하지 않고 커스텀 캐릭터 모델과 애니메이터로 도구 착용을 제어합니다.
4. 모바일 크로스플랫폼 터치 제스처 및 안전 영역
스마트폰은 마우스 우클릭이 없습니다. 모바일 환경에 특화된 직관적인 터치 제스처를 설계해야 합니다:
- 단순 탭 대 드래그 구분: 슬롯을 가볍게 탭하면 아이템 정보 창을 띄우고, 길게 누른 채 밀면 슬롯 이동 드래그로 전환합니다.
- 400ms 롱프레스 액션 시트: 터치를 0.4초간 유지하면 '스택 분할', '버리기', '사용하기' 메뉴가 담긴 컨텍스트 창을 띄웁니다.
- 화면 하단 홈 인디케이터 여백: 최신 iOS 홈바에 조작이 간섭받지 않도록 화면 하단에서 최소 20픽셀 이상의 여백을 둡니다.
- 터치 감지 범위 25% 확장: 손가락으로 화면이 가려지는 현상을 고려하여 모바일에서는 슬롯의 충돌 판정 영역을 시각 크기보다 25% 넓게 잡습니다.
5. 상용 배포 검증 및 네트워크 동기화 체크리스트
수천 명의 동시 접속자가 플레이하는 라이브 환경에서 무결성을 유지하기 위한 QA 체크리스트입니다:
- 연타 스왑 공격 검증: 두 슬롯 사이를 비정상적으로 빠르게 반복 드래그하여 슬롯 데이터가 꼬이거나 사라지지 않는지 확인합니다.
- 아이템 나누기(Split) 수학 검증: 1개인 아이템을 나눌 때의 예외 처리와 99개 아이템을 정확히 49개, 50개로 오차 없이 분할하는지 검사합니다.
- 해상도 리사이징 대응: PC 4K 환경부터 모바일 세로/가로 모드 전환 시 슬롯 좌표가 튀지 않고 정확히 추적되는지 검증합니다.
- 조작 피드백 사운드: 아이템을 집어 들었을 때와 슬롯에 안착시켰을 때 경쾌한 클릭 효과음을 재생하여 조작감을 향상시킵니다.
Frequently Asked Questions
로블록스 기본 백팩(Backpack)을 비활성화해야 하는 이유는 무엇인가요?
기본 백팩은 디자인 커스터마이징이 불가능하고, 아이템 나누기나 슬롯 정렬을 지원하지 않으며 모바일 터치 오류가 잦습니다. SetCoreGuiEnabled(Enum.CoreGuiType.Backpack, false)로 비활성화하고 전용 GUI를 만드는 것이 권장됩니다.
드래그 중인 아이템이 마우스 커서를 부드럽게 따라오게 하려면 어떻게 하나요?
화면 최상위 ScreenGui에 아이콘을 복제한 후 AnchorPoint를 (0.5, 0.5)로 설정하고, InputChanged 이벤트에서 input.Position 좌표로 고스트 아이콘의 Position을 실시간 갱신해주면 됩니다.
빠르게 드래그할 때 아이템이 복사되는 버그를 어떻게 막나요?
모든 슬롯 교체 연산은 반드시 서버에서 승인되어야 합니다. 서버는 원래 슬롯에 해당 아이템이 실제로 존재하는지 확인하고, 대상 슬롯과 인덱스를 맞바꾼 후 클라이언트로 최종 결과를 동기화합니다.
모바일에서 우클릭 없이 아이템을 어떻게 반으로 나누나요?
400ms(0.4초) 이상 슬롯을 누르고 있으면 나타나는 롱프레스 팝업 슬라이더를 구현하여 모바일 플레이어도 손쉽게 수량을 분할할 수 있도록 합니다.