···117 return
118 end
119 local hotbar = replicaForPlayer.Data.hotbar
120- if not hotbar or not hotbar[slot] then
121 return
122 end
123- replicaForPlayer:FireServer("SelectHotbarSlot", slot)
00124end
125126ClientState.Changed = changed.Event
···117 return
118 end
119 local hotbar = replicaForPlayer.Data.hotbar
120+ if not hotbar then
121 return
122 end
123+ if slot and slot >= 1 and slot <= HOTBAR_SIZE then
124+ replicaForPlayer:FireServer("SelectHotbarSlot", slot)
125+ end
126end
127128ClientState.Changed = changed.Event
+53-25
ReplicatedStorage/Shared/PlacementManager.lua
···79 if current:IsA("BasePart") then
80 return current
81 end
82- current = current.Parent
83 end
84 return nil
85end
···265end
266267-- Gets the block and normalid of the block (and surface) the player is looking at
268-function PlacementManager:Raycast()
269 if not Mouse then
270 Mouse = game:GetService("Players").LocalPlayer:GetMouse()
271 end
272 local chunkFolder = ensureChunkFolder()
273 if not chunkFolder then
274- clearSelection("chunk folder missing")
00275 script.RaycastResult.Value = nil
276 return
277 end
···285 local ray = Mouse.UnitRay
286 local result = workspace:Raycast(ray.Origin, ray.Direction * MAX_REACH, raycastParams)
287 if not result then
288- clearSelection("raycast miss")
00289 script.RaycastResult.Value = nil
290 debugPlacementLog("[PLACE][CLIENT][RAYCAST]", "miss")
291 return
···293294 local objLookingAt = result.Instance
295 if not objLookingAt then
296- clearSelection("raycast nil instance")
00297 script.RaycastResult.Value = nil
298 debugPlacementWarn("[PLACE][CLIENT][RAYCAST]", "nil instance in result")
299 return
···308 "parent",
309 objLookingAt.Parent and objLookingAt.Parent:GetFullName() or "nil"
310 )
311- clearSelection("target not in chunk folder")
00312 script.RaycastResult.Value = nil
313 return
314 end
···318 "chunk flagged ns",
319 hitChunkFolder:GetFullName()
320 )
321- clearSelection("target chunk marked ns")
00322 script.RaycastResult.Value = nil
323 return
324 end
···327 local blockRoot = findBlockRoot(objLookingAt, chunkFolder) or objLookingAt
328 local chunkName, blockName = findChunkAndBlock(blockRoot)
329 if not chunkName or not blockName then
330- clearSelection("failed to resolve chunk/block")
00331 script.RaycastResult.Value = nil
332 return
333 end
···338 return Util.BlockPosStringToCoords(blockName)
339 end)
340 if not okChunk or not okBlock then
341- clearSelection("failed to parse chunk/block names")
00342 script.RaycastResult.Value = nil
343 return
344 end
···347348 -- block is being optimistically broken, do not highlight it
349 if getPendingBreak(chunkKey, blockKey) then
350- clearSelection("block pending break")
00351 script.RaycastResult.Value = nil
352 return
353 end
···356 local chunk = ChunkManager:GetChunk(chunkCoords.X, chunkCoords.Y, chunkCoords.Z)
357 local blockData = chunk and chunk:GetBlockAt(blockCoords.X, blockCoords.Y, blockCoords.Z)
358 if not blockData or blockData == 0 or blockData.id == 0 then
359- clearSelection("block missing/air")
00360 script.RaycastResult.Value = nil
361 return
362 end
363 local blockInstance = resolveBlockInstance(chunkFolder, chunkName, blockName) or blockRoot
364 if not blockInstance then
365- clearSelection("missing block instance")
00366 script.RaycastResult.Value = nil
367 return
368 end
369370 lastRaycastFailure = nil
371- if lastSelectedChunkKey ~= chunkKey or lastSelectedBlockKey ~= blockKey then
372- setSelection(blockInstance, PlacementManager.ChunkFolder)
373- lastSelectedChunkKey = chunkKey
374- lastSelectedBlockKey = blockKey
00375 end
376 script.RaycastResult.Value = objLookingAt
377 lastNormalId = vectorToNormalId(result.Normal)
···400-- FIRES REMOTE
401function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockId: string)
402 debugPlacementLog("[PLACE][CLIENT][PLACE_CALL]", "chunk", cx, cy, cz, "block", x, y, z, "blockId", blockId)
0000403 if typeof(cx) ~= "number" or typeof(cy) ~= "number" or typeof(cz) ~= "number" then
404 debugPlacementWarn("[PLACE][CLIENT][REJECT]", "chunk type", cx, cy, cz, x, y, z, blockId)
405 return
···551 chunk:RemoveBlock(x, y, z)
552end
553554-function PlacementManager:GetBlockAtMouse(): nil | {chunk:Vector3, block: Vector3}
555 pcall(function()
556- PlacementManager:Raycast()
557 end)
558 local selectedPart = PlacementManager:RaycastGetResult()
559 --print(selectedPart and selectedPart:GetFullName() or nil)
560 if selectedPart == nil then
561- clearSelection()
00562 script.RaycastResult.Value = nil
563 debugPlacementLog("[PLACE][CLIENT][TARGET]", "no selectedPart after raycast", lastRaycastFailure)
564 return nil
···607608end
609610-function PlacementManager:GetTargetAtMouse(): nil | {chunk:Vector3, block: Vector3, normal: Enum.NormalId}
611- local hit = PlacementManager:GetBlockAtMouse()
612 if not hit then
613 return nil
614 end
···621 }
622end
623624-function PlacementManager:GetPlacementAtMouse(): nil | {chunk:Vector3, block: Vector3}
625- local hit = PlacementManager:GetTargetAtMouse()
626 if not hit then
627 return nil
628 end
···647 }
648end
649650-function PlacementManager:DebugGetPlacementOrWarn()
651- local placement = PlacementManager:GetPlacementAtMouse()
652 if not placement then
653 debugPlacementWarn("[PLACE][CLIENT][REJECT]", "no placement target under mouse", lastRaycastFailure)
654 end
···79 if current:IsA("BasePart") then
80 return current
81 end
82+ current = current.Parent
83 end
84 return nil
85end
···265end
266267-- Gets the block and normalid of the block (and surface) the player is looking at
268+function PlacementManager:Raycast(skipSelection: boolean?)
269 if not Mouse then
270 Mouse = game:GetService("Players").LocalPlayer:GetMouse()
271 end
272 local chunkFolder = ensureChunkFolder()
273 if not chunkFolder then
274+ if not skipSelection then
275+ clearSelection("chunk folder missing")
276+ end
277 script.RaycastResult.Value = nil
278 return
279 end
···287 local ray = Mouse.UnitRay
288 local result = workspace:Raycast(ray.Origin, ray.Direction * MAX_REACH, raycastParams)
289 if not result then
290+ if not skipSelection then
291+ clearSelection("raycast miss")
292+ end
293 script.RaycastResult.Value = nil
294 debugPlacementLog("[PLACE][CLIENT][RAYCAST]", "miss")
295 return
···297298 local objLookingAt = result.Instance
299 if not objLookingAt then
300+ if not skipSelection then
301+ clearSelection("raycast nil instance")
302+ end
303 script.RaycastResult.Value = nil
304 debugPlacementWarn("[PLACE][CLIENT][RAYCAST]", "nil instance in result")
305 return
···314 "parent",
315 objLookingAt.Parent and objLookingAt.Parent:GetFullName() or "nil"
316 )
317+ if not skipSelection then
318+ clearSelection("target not in chunk folder")
319+ end
320 script.RaycastResult.Value = nil
321 return
322 end
···326 "chunk flagged ns",
327 hitChunkFolder:GetFullName()
328 )
329+ if not skipSelection then
330+ clearSelection("target chunk marked ns")
331+ end
332 script.RaycastResult.Value = nil
333 return
334 end
···337 local blockRoot = findBlockRoot(objLookingAt, chunkFolder) or objLookingAt
338 local chunkName, blockName = findChunkAndBlock(blockRoot)
339 if not chunkName or not blockName then
340+ if not skipSelection then
341+ clearSelection("failed to resolve chunk/block")
342+ end
343 script.RaycastResult.Value = nil
344 return
345 end
···350 return Util.BlockPosStringToCoords(blockName)
351 end)
352 if not okChunk or not okBlock then
353+ if not skipSelection then
354+ clearSelection("failed to parse chunk/block names")
355+ end
356 script.RaycastResult.Value = nil
357 return
358 end
···361362 -- block is being optimistically broken, do not highlight it
363 if getPendingBreak(chunkKey, blockKey) then
364+ if not skipSelection then
365+ clearSelection("block pending break")
366+ end
367 script.RaycastResult.Value = nil
368 return
369 end
···372 local chunk = ChunkManager:GetChunk(chunkCoords.X, chunkCoords.Y, chunkCoords.Z)
373 local blockData = chunk and chunk:GetBlockAt(blockCoords.X, blockCoords.Y, blockCoords.Z)
374 if not blockData or blockData == 0 or blockData.id == 0 then
375+ if not skipSelection then
376+ clearSelection("block missing/air")
377+ end
378 script.RaycastResult.Value = nil
379 return
380 end
381 local blockInstance = resolveBlockInstance(chunkFolder, chunkName, blockName) or blockRoot
382 if not blockInstance then
383+ if not skipSelection then
384+ clearSelection("missing block instance")
385+ end
386 script.RaycastResult.Value = nil
387 return
388 end
389390 lastRaycastFailure = nil
391+ if not skipSelection then
392+ if lastSelectedChunkKey ~= chunkKey or lastSelectedBlockKey ~= blockKey then
393+ setSelection(blockInstance, PlacementManager.ChunkFolder)
394+ lastSelectedChunkKey = chunkKey
395+ lastSelectedBlockKey = blockKey
396+ end
397 end
398 script.RaycastResult.Value = objLookingAt
399 lastNormalId = vectorToNormalId(result.Normal)
···422-- FIRES REMOTE
423function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockId: string)
424 debugPlacementLog("[PLACE][CLIENT][PLACE_CALL]", "chunk", cx, cy, cz, "block", x, y, z, "blockId", blockId)
425+ if blockId == "hand" then
426+ debugPlacementWarn("[PLACE][CLIENT][REJECT]", "hand cannot place")
427+ return
428+ end
429 if typeof(cx) ~= "number" or typeof(cy) ~= "number" or typeof(cz) ~= "number" then
430 debugPlacementWarn("[PLACE][CLIENT][REJECT]", "chunk type", cx, cy, cz, x, y, z, blockId)
431 return
···577 chunk:RemoveBlock(x, y, z)
578end
579580+function PlacementManager:GetBlockAtMouse(skipSelection: boolean?): nil | {chunk:Vector3, block: Vector3}
581 pcall(function()
582+ PlacementManager:Raycast(skipSelection)
583 end)
584 local selectedPart = PlacementManager:RaycastGetResult()
585 --print(selectedPart and selectedPart:GetFullName() or nil)
586 if selectedPart == nil then
587+ if not skipSelection then
588+ clearSelection()
589+ end
590 script.RaycastResult.Value = nil
591 debugPlacementLog("[PLACE][CLIENT][TARGET]", "no selectedPart after raycast", lastRaycastFailure)
592 return nil
···635636end
637638+function PlacementManager:GetTargetAtMouse(skipSelection: boolean?): nil | {chunk:Vector3, block: Vector3, normal: Enum.NormalId}
639+ local hit = PlacementManager:GetBlockAtMouse(skipSelection)
640 if not hit then
641 return nil
642 end
···649 }
650end
651652+function PlacementManager:GetPlacementAtMouse(skipSelection: boolean?): nil | {chunk:Vector3, block: Vector3}
653+ local hit = PlacementManager:GetTargetAtMouse(skipSelection)
654 if not hit then
655 return nil
656 end
···675 }
676end
677678+function PlacementManager:DebugGetPlacementOrWarn(skipSelection: boolean?)
679+ local placement = PlacementManager:GetPlacementAtMouse(skipSelection)
680 if not placement then
681 debugPlacementWarn("[PLACE][CLIENT][REJECT]", "no placement target under mouse", lastRaycastFailure)
682 end
+1-4
ServerScriptService/Actor/ClientState.lua
···81 if selectedSlot < 1 or selectedSlot > HOTBAR_SIZE then
82 return (#hotbar > 0) and 1 or 0
83 end
84- if not hotbar[selectedSlot] then
85- return (#hotbar > 0) and 1 or 0
86- end
87 return selectedSlot
88end
89···138 if not hotbar then
139 return
140 end
141- if slot and slot >= 1 and slot <= HOTBAR_SIZE and hotbar[slot] then
142 replica:Set({"selectedSlot"}, slot)
143 end
144 end
···81 if selectedSlot < 1 or selectedSlot > HOTBAR_SIZE then
82 return (#hotbar > 0) and 1 or 0
83 end
00084 return selectedSlot
85end
86···135 if not hotbar then
136 return
137 end
138+ if slot and slot >= 1 and slot <= HOTBAR_SIZE then
139 replica:Set({"selectedSlot"}, slot)
140 end
141 end
+23-20
StarterGui/Hotbar/LocalScript.client.lua
···53end
5455local function resolveSelectedSlot(slots, desired)
56- if desired and desired >= 1 and desired <= HOTBAR_SIZE and slots[desired] ~= "" then
57 return desired
58 end
59 for i = 1, HOTBAR_SIZE do
···146 names = nextNames,
147 selected = nextSelected,
148 })
149- local id = nextSlots[nextSelected] or ""
0150 local name = ""
151- if id ~= "" then
152- name = nextNames[id] or id
153 end
154- PlacementState:SetSelected(id, name)
155 end
156157 self._setSelected = function(slot: number)
158 if slot < 1 or slot > HOTBAR_SIZE then
159 return
160 end
161- local info = ClientState:GetSlotInfo(slot)
162- if not info then
163- return
164- end
165 ClientState:SetSelectedSlot(slot)
166 self:setState({
167 selected = slot,
168 })
169- local id = tostring(info.id)
170- local name = info.name or id
171- Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", id, "name", name)
172- PlacementState:SetSelected(id, name)
0000173 end
174175 self._handleInput = function(input: InputObject, gameProcessedEvent: boolean)
···207 elseif input.UserInputType == Enum.UserInputType.MouseButton2 then
208 Util.StudioLog("[INPUT][CLIENT]", "MouseButton2", "processed", gameProcessedEvent)
209 -- Allow click even if gameProcessedEvent (UI can set this), but only if we're actually pointing at a block
210- local mouseBlock = PM:DebugGetPlacementOrWarn()
211 if not mouseBlock then
212 return
213 end
···249 return
250 end
251 local delta = direction > 0 and -1 or 1
252- local nextSlot = math.clamp(self.state.selected + delta, 1, HOTBAR_SIZE)
253 if nextSlot ~= self.state.selected then
254 self._setSelected(nextSlot)
255 end
···268 self._syncFromClientState()
269 self:_refreshViewports()
270 -- initialize selection broadcast
271- local id = self.state.slots and self.state.slots[self.state.selected] or ""
0272 local name = ""
273- if id ~= "" and self.state.names then
274- name = self.state.names[id] or id
275 end
276- PlacementState:SetSelected(id, name)
277end
278279function Hotbar:willUnmount()
···345 }),
346 IndexLabel = Roact.createElement("TextLabel", {
347 BackgroundTransparency = 1,
348- Position = UDim2.fromOffset(4, 2),
349 Size = UDim2.fromOffset(18, 14),
350 Font = Enum.Font.Gotham,
351 Text = i == 10 and "0" or tostring(i),
···412 BorderSizePixel = 0,
413 Position = UDim2.new(0.5, 0, 1, -80-10),
414 Size = UDim2.fromOffset(0, 25),
0415 }, {
416 Corner = Roact.createElement("UICorner", {
417 CornerRadius = UDim.new(0, 8),
···53end
5455local function resolveSelectedSlot(slots, desired)
56+ if desired and desired >= 1 and desired <= HOTBAR_SIZE then
57 return desired
58 end
59 for i = 1, HOTBAR_SIZE do
···146 names = nextNames,
147 selected = nextSelected,
148 })
149+ local rawId = nextSlots[nextSelected] or ""
150+ local effectiveId = rawId ~= "" and rawId or "hand"
151 local name = ""
152+ if rawId ~= "" then
153+ name = nextNames[rawId] or rawId
154 end
155+ PlacementState:SetSelected(effectiveId, name)
156 end
157158 self._setSelected = function(slot: number)
159 if slot < 1 or slot > HOTBAR_SIZE then
160 return
161 end
0000162 ClientState:SetSelectedSlot(slot)
163 self:setState({
164 selected = slot,
165 })
166+ local rawId = self.state.slots[slot] or ""
167+ local effectiveId = rawId ~= "" and rawId or "hand"
168+ local name = ""
169+ if rawId ~= "" then
170+ name = self.state.names[rawId] or rawId
171+ end
172+ Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", effectiveId, "name", name)
173+ PlacementState:SetSelected(effectiveId, name)
174 end
175176 self._handleInput = function(input: InputObject, gameProcessedEvent: boolean)
···208 elseif input.UserInputType == Enum.UserInputType.MouseButton2 then
209 Util.StudioLog("[INPUT][CLIENT]", "MouseButton2", "processed", gameProcessedEvent)
210 -- Allow click even if gameProcessedEvent (UI can set this), but only if we're actually pointing at a block
211+ local mouseBlock = PM:DebugGetPlacementOrWarn(true) -- skip selection outline on right click
212 if not mouseBlock then
213 return
214 end
···250 return
251 end
252 local delta = direction > 0 and -1 or 1
253+ local nextSlot = ((self.state.selected - 1 + delta) % HOTBAR_SIZE) + 1
254 if nextSlot ~= self.state.selected then
255 self._setSelected(nextSlot)
256 end
···269 self._syncFromClientState()
270 self:_refreshViewports()
271 -- initialize selection broadcast
272+ local rawId = self.state.slots and self.state.slots[self.state.selected] or ""
273+ local effectiveId = rawId ~= "" and rawId or "hand"
274 local name = ""
275+ if rawId ~= "" and self.state.names then
276+ name = self.state.names[rawId] or rawId
277 end
278+ PlacementState:SetSelected(effectiveId, name)
279end
280281function Hotbar:willUnmount()
···347 }),
348 IndexLabel = Roact.createElement("TextLabel", {
349 BackgroundTransparency = 1,
350+ Position = UDim2.fromOffset(8, 4),
351 Size = UDim2.fromOffset(18, 14),
352 Font = Enum.Font.Gotham,
353 Text = i == 10 and "0" or tostring(i),
···414 BorderSizePixel = 0,
415 Position = UDim2.new(0.5, 0, 1, -80-10),
416 Size = UDim2.fromOffset(0, 25),
417+ Visible = selectedName ~= "",
418 }, {
419 Corner = Roact.createElement("UICorner", {
420 CornerRadius = UDim.new(0, 8),