--!native --!optimize 2 if not game:IsLoaded() then game.Loaded:Wait() end local ReplicatedStorage = game:GetService("ReplicatedStorage") local UIS = game:GetService("UserInputService") local TextChatService = game:GetService("TextChatService") local objects = ReplicatedStorage:WaitForChild("Objects", 9e9) objects:WaitForChild("MLLoaded", 9e9) objects:WaitForChild("CSMLLoaded", 9e9) local Roact = require(ReplicatedStorage.Packages.roact) local PM = require(ReplicatedStorage.Shared.PlacementManager) local BlockManager = require(ReplicatedStorage.Shared.ChunkManager.BlockManager) local PlacementState = require(ReplicatedStorage.Shared.PlacementState) local Util = require(ReplicatedStorage.Shared.Util) local ClientState = require(ReplicatedStorage.Shared.ClientState) local HOTBAR_SIZE = 10 local keyToSlot = { [Enum.KeyCode.One] = 1, [Enum.KeyCode.Two] = 2, [Enum.KeyCode.Three] = 3, [Enum.KeyCode.Four] = 4, [Enum.KeyCode.Five] = 5, [Enum.KeyCode.Six] = 6, [Enum.KeyCode.Seven] = 7, [Enum.KeyCode.Eight] = 8, [Enum.KeyCode.Nine] = 9, [Enum.KeyCode.Zero] = 10, } local colors = { base = Color3.fromRGB(30, 30, 46), slot = Color3.fromRGB(17, 17, 27), stroke = Color3.fromRGB(88, 91, 112), selectedStroke = Color3.fromRGB(137, 180, 250), text = Color3.fromRGB(205, 214, 244), subtext = Color3.fromRGB(166, 173, 200), } local function isTextInputFocused(): boolean if UIS:GetFocusedTextBox() then return true end local config = TextChatService:FindFirstChildOfClass("ChatInputBarConfiguration") return config ~= nil and config.IsFocused end local function resolveSelectedSlot(slots, desired) if desired and desired >= 1 and desired <= HOTBAR_SIZE then return desired end for i = 1, HOTBAR_SIZE do if slots[i] and slots[i] ~= "" then return i end end return desired or 1 end local function buildHotbarFromState() local slots = table.create(HOTBAR_SIZE) local names = {} for i = 1, HOTBAR_SIZE do local info = ClientState:GetSlotInfo(i) if info then slots[i] = tostring(info.id) names[slots[i]] = info.name or slots[i] else slots[i] = "" end end local selected = resolveSelectedSlot(slots, ClientState:GetSelectedSlot()) return slots, names, selected end local function ensurePreviewRig(part: Instance) for _, descendant in ipairs(part:GetDescendants()) do if descendant:IsA("BasePart") then descendant.Anchored = true descendant.CanCollide = false end end if part:IsA("BasePart") then part.Anchored = true part.CanCollide = false end end local function updateViewport(viewport: ViewportFrame, blockId: string) viewport:ClearAllChildren() if blockId == "" then return end local camera = Instance.new("Camera") camera.Parent = viewport viewport.CurrentCamera = camera local world = Instance.new("WorldModel") world.Parent = viewport local resolvedId = tonumber(blockId) or blockId local preview = BlockManager:GetBlock(resolvedId) preview.Parent = world ensurePreviewRig(preview) local cf, size if preview:IsA("BasePart") then cf = preview.CFrame size = preview.Size else cf, size = preview:GetBoundingBox() end local maxSize = math.max(size.X, size.Y, size.Z) local distance = maxSize * 1.8 local target = cf.Position camera.CFrame = CFrame.new(target + Vector3.new(distance, distance, distance), target) preview:PivotTo(CFrame.new()) end local Hotbar = Roact.Component:extend("Hotbar") function Hotbar:init() local slots, names, selected = buildHotbarFromState() self.state = { slots = slots, names = names, selected = selected, } self._syncFromClientState = function() local nextSlots, nextNames, nextSelected = buildHotbarFromState() nextSelected = resolveSelectedSlot(nextSlots, nextSelected or self.state.selected) self:setState({ slots = nextSlots, names = nextNames, selected = nextSelected, }) local rawId = nextSlots[nextSelected] or "" local effectiveId = rawId ~= "" and rawId or "hand" local name = "" if rawId ~= "" then name = nextNames[rawId] or rawId end PlacementState:SetSelected(effectiveId, name) end self._setSelected = function(slot: number) if slot < 1 or slot > HOTBAR_SIZE then return end ClientState:SetSelectedSlot(slot) self:setState({ selected = slot, }) local rawId = self.state.slots[slot] or "" local effectiveId = rawId ~= "" and rawId or "hand" local name = "" if rawId ~= "" then name = self.state.names[rawId] or rawId end Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", effectiveId, "name", name) PlacementState:SetSelected(effectiveId, name) end self._handleInput = function(input: InputObject, gameProcessedEvent: boolean) if isTextInputFocused() then return end local slot = keyToSlot[input.KeyCode] if slot then if gameProcessedEvent then return end self._setSelected(slot) return end if input.UserInputType == Enum.UserInputType.MouseButton1 then Util.StudioLog("[INPUT][CLIENT]", "MouseButton1", "processed", gameProcessedEvent) -- Allow click even if gameProcessedEvent (UI can set this), but only if we're actually pointing at a block if not PM:GetBlockAtMouse() then return end local mouseBlock = PM:GetBlockAtMouse() if not mouseBlock then return end PM:BreakBlock( mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z ) elseif input.UserInputType == Enum.UserInputType.MouseButton2 then Util.StudioLog("[INPUT][CLIENT]", "MouseButton2", "processed", gameProcessedEvent) -- Allow click even if gameProcessedEvent (UI can set this), but only if we're actually pointing at a block local mouseBlock = PM:DebugGetPlacementOrWarn(true) -- skip selection outline on right click if not mouseBlock then return end local id = PlacementState:GetSelected() if not id or id == "" then Util.StudioWarn("[PLACE][CLIENT][REJECT]", "no selected id") return end Util.StudioLog( "[PLACE][CLIENT][SEND][CLICK]", "chunk", mouseBlock.chunk, "block", mouseBlock.block, "id", id ) PM:PlaceBlock( mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z, id ) end end self._handleScroll = function(input: InputObject, gameProcessedEvent: boolean) if gameProcessedEvent or isTextInputFocused() then return end if input.UserInputType ~= Enum.UserInputType.MouseWheel then return end local direction = input.Position.Z if direction == 0 then return end local delta = direction > 0 and -1 or 1 local nextSlot = ((self.state.selected - 1 + delta) % HOTBAR_SIZE) + 1 if nextSlot ~= self.state.selected then self._setSelected(nextSlot) end end self._viewportRefs = {} self._viewportState = {} end function Hotbar:didMount() self._connections = { ClientState.Changed:Connect(self._syncFromClientState), UIS.InputBegan:Connect(self._handleInput), UIS.InputChanged:Connect(self._handleScroll), } self._syncFromClientState() self:_refreshViewports() -- initialize selection broadcast local rawId = self.state.slots and self.state.slots[self.state.selected] or "" local effectiveId = rawId ~= "" and rawId or "hand" local name = "" if rawId ~= "" and self.state.names then name = self.state.names[rawId] or rawId end PlacementState:SetSelected(effectiveId, name) end function Hotbar:willUnmount() for _, conn in ipairs(self._connections or {}) do conn:Disconnect() end self._connections = nil end function Hotbar:didUpdate(prevProps, prevState) if prevState.slots ~= self.state.slots then self:_refreshViewports() end end function Hotbar:_refreshViewports() for i = 1, HOTBAR_SIZE do local viewport = self._viewportRefs[i] if viewport then local id = self.state.slots[i] or "" if self._viewportState[i] ~= id then self._viewportState[i] = id updateViewport(viewport, id) end end end end function Hotbar:render() local slotElements = {} local selectedId = self.state.slots[self.state.selected] or "" local selectedName = "" if selectedId ~= "" and self.state.names then selectedName = self.state.names[selectedId] or selectedId end for i = 1, HOTBAR_SIZE do local id = self.state.slots[i] or "" local isSelected = i == self.state.selected local displayName = id ~= "" and (self.state.names and self.state.names[id] or id) or "" slotElements[`Slot{i-1}`] = Roact.createElement("TextButton", { Size = UDim2.fromOffset(50, 50), BackgroundColor3 = colors.slot, BorderSizePixel = 0, AutoButtonColor = false, ClipsDescendants = true, Text = "", LayoutOrder = i, [Roact.Event.Activated] = function() self._setSelected(i) end, }, { Corner = Roact.createElement("UICorner", { CornerRadius = UDim.new(0, 13), }), Stroke = Roact.createElement("UIStroke", { Color = isSelected and colors.selectedStroke or colors.stroke, Thickness = isSelected and 2 or 1, ApplyStrokeMode = Enum.ApplyStrokeMode.Border }), Preview = Roact.createElement("ViewportFrame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 1, 0), BorderSizePixel = 0, [Roact.Ref] = function(r) self._viewportRefs[i] = r end, }), IndexLabel = Roact.createElement("TextLabel", { BackgroundTransparency = 1, Position = UDim2.fromOffset(8, 4), Size = UDim2.fromOffset(18, 14), Font = Enum.Font.Gotham, Text = i == 10 and "0" or tostring(i), TextColor3 = colors.subtext, TextSize = 12, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Top, }), IdLabel = Roact.createElement("TextLabel", { BackgroundTransparency = 1, Position = UDim2.fromOffset(4, 26), Size = UDim2.new(1, -8, 0, 18), Font = Enum.Font.GothamBold, Text = displayName, TextColor3 = colors.text, TextSize = 15, TextWrapped = true, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Bottom, }), }) end local hotbarFrame = Roact.createElement("Frame", { AnchorPoint = Vector2.new(0.5, 1), AutomaticSize = Enum.AutomaticSize.X, BackgroundColor3 = colors.base, BorderSizePixel = 0, Position = UDim2.new(0.5, 0, 1, -20), Size = UDim2.fromOffset(0, 58), }, { Corner = Roact.createElement("UICorner", { CornerRadius = UDim.new(0, 16), }), Stroke = Roact.createElement("UIStroke", { Color = colors.selectedStroke, Thickness = 2, ApplyStrokeMode = Enum.ApplyStrokeMode.Border }), Padding = Roact.createElement("UIPadding", { PaddingLeft = UDim.new(0, 5), PaddingRight = UDim.new(0, 5), PaddingTop = UDim.new(0, 5), PaddingBottom = UDim.new(0, 5), }), Slots = Roact.createElement("Frame", { AutomaticSize = Enum.AutomaticSize.X, BackgroundTransparency = 1, Size = UDim2.new(0, 0, 1, 0), }, { Layout = Roact.createElement("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, HorizontalAlignment = Enum.HorizontalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center, Padding = UDim.new(0, 5), }), Slots = Roact.createFragment(slotElements), }), }) local selectedNameFrame = Roact.createElement("Frame", { AnchorPoint = Vector2.new(0.5, 1), AutomaticSize = Enum.AutomaticSize.X, BackgroundColor3 = colors.base, BorderSizePixel = 0, Position = UDim2.new(0.5, 0, 1, -80-10), Size = UDim2.fromOffset(0, 25), Visible = selectedName ~= "", }, { Corner = Roact.createElement("UICorner", { CornerRadius = UDim.new(0, 8), }), Stroke = Roact.createElement("UIStroke", { Color = colors.selectedStroke, Thickness = 2, ApplyStrokeMode = Enum.ApplyStrokeMode.Border }), Padding = Roact.createElement("UIPadding", { PaddingLeft = UDim.new(0, 18), PaddingRight = UDim.new(0, 18), PaddingTop = UDim.new(0, 2), PaddingBottom = UDim.new(0, 2), }), Label = Roact.createElement("TextLabel", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 1, 0), Font = Enum.Font.JosefinSans, RichText = true, Text = selectedName ~= "" and selectedName or " ", TextColor3 = colors.text, TextSize = 19, TextWrapped = true, AutomaticSize = Enum.AutomaticSize.X, TextXAlignment = Enum.TextXAlignment.Center, TextYAlignment = Enum.TextYAlignment.Center }), }) return Roact.createFragment({ Hotbar = hotbarFrame, SelectedName = selectedNameFrame, }) end local handle = Roact.mount(Roact.createElement(Hotbar), script.Parent, "RoactHotbar") script.AncestryChanged:Connect(function(_, parent) if parent == nil then Roact.unmount(handle) end end)