--!native --!optimize 2 local PlacementManager = {} local ChunkManager = require("./ChunkManager") local Util = require("./Util") local DEBUG_PLACEMENT = true local function debugPlacementLog(...: any) if DEBUG_PLACEMENT then Util.StudioLog(...) end end local function debugPlacementWarn(...: any) if DEBUG_PLACEMENT then Util.StudioWarn(...) end end PlacementManager.ChunkFolder = ChunkManager.ChunkFolder local raycastParams = RaycastParams.new() raycastParams.FilterDescendantsInstances = {PlacementManager.ChunkFolder} raycastParams.FilterType = Enum.RaycastFilterType.Include raycastParams.IgnoreWater = true local Mouse: Mouse = nil local lastNormalId: Enum.NormalId? = nil local lastRaycastFailure: string? = nil local lastSelectedChunkKey: string? = nil local lastSelectedBlockKey: string? = nil local BREAK_ROLLBACK_TIMEOUT = 0.6 local pendingBreaks = {} local clearSelection if _G.__BLOCKSCRAFT_PLACEMENT_MANAGER then return _G.__BLOCKSCRAFT_PLACEMENT_MANAGER end _G.__BLOCKSCRAFT_PLACEMENT_MANAGER = PlacementManager PlacementManager.SelectionBox = script.SelectionBox:Clone() PlacementManager.SelectionBox.Name = "$SelectionBox"..(game:GetService("RunService"):IsServer() and "_SERVER" or "") PlacementManager.SelectionBox.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain") PlacementManager.SelectionBox.Adornee = nil PlacementManager.SelectionBox:GetPropertyChangedSignal("Adornee"):Connect(function() local adornee = PlacementManager.SelectionBox.Adornee if not adornee then return end adornee.AncestryChanged:Connect(function(_, parent) if not parent then clearSelection("adornee destroyed") end end) end) -- Trash method TODO: Fix this local function findChunkFolderFromDescendant(inst: Instance): Instance? local current = inst while current and current.Parent do if current.Parent == PlacementManager.ChunkFolder then return current end -- Fallback: match by name in case the ChunkFolder reference differs (e.g. recreated/parented later) if current.Parent:IsA("Folder") and current.Parent.Name == (PlacementManager.ChunkFolder and PlacementManager.ChunkFolder.Name) then return current end current = current.Parent end return nil end local function findBlockRoot(inst: Instance, chunkFolder: Instance): Instance? local current = inst while current and current ~= chunkFolder do if current:IsA("BasePart") then return current end current = current.Parent end return nil end local function resolveBlockInstance(chunkFolder: Instance, chunkName: string, blockName: string): Instance? local chunkInst = chunkFolder:FindFirstChild(chunkName) if not chunkInst then return nil end return chunkInst:FindFirstChild(blockName) end clearSelection = function(reason: string?) PlacementManager.SelectionBox.Adornee = nil PlacementManager.SelectionBox.Parent = nil lastNormalId = nil lastSelectedChunkKey = nil lastSelectedBlockKey = nil if reason then lastRaycastFailure = reason end end local function setSelection(target: Instance, parent: Instance) PlacementManager.SelectionBox.Parent = parent PlacementManager.SelectionBox.Adornee = target end local function findChunkAndBlock(inst: Instance): (string?, string?) local root = PlacementManager.ChunkFolder if not root then return nil, nil end local current = inst while current and current.Parent do -- case: current parent is the chunk folder root; then current is the chunk itself (no block name yet) if current.Parent == root then return current.Name, inst.Name end -- case: grandparent is chunk folder root; parent is chunk, current is block/model if current.Parent.Parent == root then return current.Parent.Name, current.Name end current = current.Parent end return nil, nil end local function vectorToNormalId(normal: Vector3): Enum.NormalId local ax, ay, az = math.abs(normal.X), math.abs(normal.Y), math.abs(normal.Z) if ax >= ay and ax >= az then return normal.X >= 0 and Enum.NormalId.Right or Enum.NormalId.Left elseif ay >= ax and ay >= az then return normal.Y >= 0 and Enum.NormalId.Top or Enum.NormalId.Bottom else return normal.Z >= 0 and Enum.NormalId.Back or Enum.NormalId.Front end end local function makeChunkKey(cx: number, cy: number, cz: number): string return `{cx},{cy},{cz}` end local function makeBlockKey(x: number, y: number, z: number): string return `{x},{y},{z}` end local function getPendingBreak(chunkKey: string, blockKey: string) local chunkMap = pendingBreaks[chunkKey] if not chunkMap then return nil end return chunkMap[blockKey] end local function clearPendingBreak(chunkKey: string, blockKey: string) local chunkMap = pendingBreaks[chunkKey] if not chunkMap then return end chunkMap[blockKey] = nil if not next(chunkMap) then pendingBreaks[chunkKey] = nil end end local function clearPendingBreaksForChunk(chunkKey: string) pendingBreaks[chunkKey] = nil end local function scheduleBreakRollback(cx: number, cy: number, cz: number, x: number, y: number, z: number) task.delay(BREAK_ROLLBACK_TIMEOUT, function() local chunkKey = makeChunkKey(cx, cy, cz) local blockKey = makeBlockKey(x, y, z) local pending = getPendingBreak(chunkKey, blockKey) if not pending then return end clearPendingBreak(chunkKey, blockKey) local chunk = ChunkManager:GetChunk(cx, cy, cz) if pending.data and chunk then chunk:CreateBlock(x, y, z, pending.data) end ChunkManager:RefreshChunk(cx, cy, cz) end) end local function normalIdToOffset(normal: Enum.NormalId): Vector3 if normal == Enum.NormalId.Top then return Vector3.new(0, 1, 0) elseif normal == Enum.NormalId.Bottom then return Vector3.new(0, -1, 0) elseif normal == Enum.NormalId.Left then return Vector3.new(-1, 0, 0) elseif normal == Enum.NormalId.Right then return Vector3.new(1, 0, 0) elseif normal == Enum.NormalId.Back then return Vector3.new(0, 0, 1) elseif normal == Enum.NormalId.Front then return Vector3.new(0, 0, -1) end return Vector3.new(0, 0, 0) end local function offsetChunkBlock(chunk: Vector3, block: Vector3, offset: Vector3) local cx, cy, cz = chunk.X, chunk.Y, chunk.Z local bx, by, bz = block.X + offset.X, block.Y + offset.Y, block.Z + offset.Z if bx < 1 then bx = 8 cx -= 1 elseif bx > 8 then bx = 1 cx += 1 end if by < 1 then by = 8 cy -= 1 elseif by > 8 then by = 1 cy += 1 end if bz < 1 then bz = 8 cz -= 1 elseif bz > 8 then bz = 1 cz += 1 end return Vector3.new(cx, cy, cz), Vector3.new(bx, by, bz) end local function getPlayerPosition(): Vector3? local player = game:GetService("Players").LocalPlayer local character = player and player.Character if not character then return nil end local root = character:FindFirstChild("HumanoidRootPart") return root and root.Position or nil end local MAX_REACH = 512 local function isWithinReach(cx: number, cy: number, cz: number, x: number, y: number, z: number): boolean -- Client-side reach loosened; rely on server authority return true end local function ensureChunkFolder(): Instance? if PlacementManager.ChunkFolder and PlacementManager.ChunkFolder.Parent then return PlacementManager.ChunkFolder end local found = workspace:FindFirstChild("$blockscraft_client") if found then PlacementManager.ChunkFolder = found return found end return nil end -- Gets the block and normalid of the block (and surface) the player is looking at function PlacementManager:Raycast() if not Mouse then Mouse = game:GetService("Players").LocalPlayer:GetMouse() end local chunkFolder = ensureChunkFolder() if not chunkFolder then clearSelection("chunk folder missing") script.RaycastResult.Value = nil return end raycastParams.FilterDescendantsInstances = {chunkFolder} local cam = workspace.CurrentCamera if not cam then lastRaycastFailure = "no camera" return end local ray = Mouse.UnitRay local result = workspace:Raycast(ray.Origin, ray.Direction * MAX_REACH, raycastParams) if not result then clearSelection("raycast miss") script.RaycastResult.Value = nil debugPlacementLog("[PLACE][CLIENT][RAYCAST]", "miss") return end local objLookingAt = result.Instance if not objLookingAt then clearSelection("raycast nil instance") script.RaycastResult.Value = nil debugPlacementWarn("[PLACE][CLIENT][RAYCAST]", "nil instance in result") return end local hitChunkFolder = findChunkFolderFromDescendant(objLookingAt) if not hitChunkFolder then debugPlacementWarn( "[PLACE][CLIENT][REJECT]", "target not in chunk folder", objLookingAt:GetFullName(), "parent", objLookingAt.Parent and objLookingAt.Parent:GetFullName() or "nil" ) clearSelection("target not in chunk folder") script.RaycastResult.Value = nil return end if hitChunkFolder:GetAttribute("ns") == true then debugPlacementWarn( "[PLACE][CLIENT][REJECT]", "chunk flagged ns", hitChunkFolder:GetFullName() ) clearSelection("target chunk marked ns") script.RaycastResult.Value = nil return end PlacementManager.ChunkFolder = chunkFolder local blockRoot = findBlockRoot(objLookingAt, chunkFolder) or objLookingAt local chunkName, blockName = findChunkAndBlock(blockRoot) if not chunkName or not blockName then clearSelection("failed to resolve chunk/block") script.RaycastResult.Value = nil return end local okChunk, chunkCoords = pcall(function() return Util.BlockPosStringToCoords(chunkName) end) local okBlock, blockCoords = pcall(function() return Util.BlockPosStringToCoords(blockName) end) if not okChunk or not okBlock then clearSelection("failed to parse chunk/block names") script.RaycastResult.Value = nil return end local chunkKey = makeChunkKey(chunkCoords.X, chunkCoords.Y, chunkCoords.Z) local blockKey = makeBlockKey(blockCoords.X, blockCoords.Y, blockCoords.Z) -- block is being optimistically broken, do not highlight it if getPendingBreak(chunkKey, blockKey) then clearSelection("block pending break") script.RaycastResult.Value = nil return end -- hide selection if block no longer exists (air/removed) local chunk = ChunkManager:GetChunk(chunkCoords.X, chunkCoords.Y, chunkCoords.Z) local blockData = chunk and chunk:GetBlockAt(blockCoords.X, blockCoords.Y, blockCoords.Z) if not blockData or blockData == 0 or blockData.id == 0 then clearSelection("block missing/air") script.RaycastResult.Value = nil return end local blockInstance = resolveBlockInstance(chunkFolder, chunkName, blockName) or blockRoot if not blockInstance then clearSelection("missing block instance") script.RaycastResult.Value = nil return end lastRaycastFailure = nil if lastSelectedChunkKey ~= chunkKey or lastSelectedBlockKey ~= blockKey then setSelection(blockInstance, PlacementManager.ChunkFolder) lastSelectedChunkKey = chunkKey lastSelectedBlockKey = blockKey end script.RaycastResult.Value = objLookingAt lastNormalId = vectorToNormalId(result.Normal) debugPlacementLog( "[PLACE][CLIENT][RAYCAST][HIT]", blockInstance:GetFullName(), "chunkFolder", hitChunkFolder:GetFullName(), "blockName", blockInstance.Name, "normal", lastNormalId.Name ) return objLookingAt, lastNormalId end function PlacementManager:RaycastGetResult() return script.RaycastResult.Value end local remotes = game:GetService("ReplicatedStorage"):WaitForChild("Remotes") local placeRemote = remotes:WaitForChild("PlaceBlock") local breakRemote = remotes:WaitForChild("BreakBlock") local tickRemote = game:GetService("ReplicatedStorage").Tick -- FIRES REMOTE function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockId: string) debugPlacementLog("[PLACE][CLIENT][PLACE_CALL]", "chunk", cx, cy, cz, "block", x, y, z, "blockId", blockId) if typeof(cx) ~= "number" or typeof(cy) ~= "number" or typeof(cz) ~= "number" then debugPlacementWarn("[PLACE][CLIENT][REJECT]", "chunk type", cx, cy, cz, x, y, z, blockId) return end if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then debugPlacementWarn("[PLACE][CLIENT][REJECT]", "block type", cx, cy, cz, x, y, z, blockId) return end if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then debugPlacementWarn("[PLACE][CLIENT][REJECT]", "block bounds", cx, cy, cz, x, y, z, blockId) return end if not isWithinReach(cx, cy, cz, x, y, z) then debugPlacementWarn("[PLACE][CLIENT][REJECT]", "reach", cx, cy, cz, x, y, z, blockId) return end -- ensure chunk is present/rendered client-side local chunk = ChunkManager:GetChunk(cx, cy, cz) if chunk and not chunk.loaded then ChunkManager:LoadChunk(cx, cy, cz) end if not chunk then debugPlacementWarn("[PLACE][CLIENT][REJECT]", "missing chunk", cx, cy, cz, x, y, z, blockId) return end -- if the client already thinks this block is the same id, skip sending if chunk then local existing = chunk:GetBlockAt(x, y, z) local existingId = existing and existing.id if existingId and tostring(existingId) == tostring(blockId) then debugPlacementLog( "[PLACE][CLIENT][SKIP]", "duplicate id", "chunk", cx, cy, cz, "block", x, y, z, "existingId", existingId, "blockId", blockId ) return else debugPlacementLog( "[PLACE][CLIENT][EXISTING]", "chunk", cx, cy, cz, "block", x, y, z, "existingId", existingId ) end end -- optimistic local apply; server will correct on tick if chunk then chunk:CreateBlock(x, y, z, { id = tonumber(blockId) or blockId, state = {}, }) end debugPlacementLog("[PLACE][CLIENT][SEND]", cx, cy, cz, x, y, z, blockId) placeRemote:FireServer(cx, cy, cz, x, y, z, blockId) end -- FIRES REMOTE function PlacementManager:BreakBlock(cx, cy, cz, x, y, z) if typeof(cx) ~= "number" or typeof(cy) ~= "number" or typeof(cz) ~= "number" then return end if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then debugPlacementWarn("[BREAK][CLIENT][REJECT]", "block type", cx, cy, cz, x, y, z) return end if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then debugPlacementWarn("[BREAK][CLIENT][REJECT]", "block bounds", cx, cy, cz, x, y, z) return end if not isWithinReach(cx, cy, cz, x, y, z) then debugPlacementWarn("[BREAK][CLIENT][REJECT]", "reach", cx, cy, cz, x, y, z) return end local chunk = ChunkManager:GetChunk(cx, cy, cz) local blockData = chunk and chunk:GetBlockAt(x, y, z) or nil local chunkKey = makeChunkKey(cx, cy, cz) local blockKey = makeBlockKey(x, y, z) if getPendingBreak(chunkKey, blockKey) then debugPlacementLog("[BREAK][CLIENT][SKIP]", "pending rollback", cx, cy, cz, x, y, z) return end pendingBreaks[chunkKey] = pendingBreaks[chunkKey] or {} pendingBreaks[chunkKey][blockKey] = { data = blockData, time = tick(), } if blockData then chunk:RemoveBlock(x, y, z) end scheduleBreakRollback(cx, cy, cz, x, y, z) debugPlacementLog("[BREAK][CLIENT][SEND]", cx, cy, cz, x, y, z) breakRemote:FireServer(cx, cy, cz, x, y, z) end -- CLIENTSIDED: only apply server-validated changes local function applyPlaceBlockLocal(cx, cy, cz, x, y, z, blockData) local chunk = ChunkManager:GetChunk(cx, cy, cz) if chunk and not chunk.loaded then ChunkManager:LoadChunk(cx, cy, cz) end chunk:CreateBlock(x, y, z, blockData) end -- CLIENTSIDED: only apply server-validated changes local function applyBreakBlockLocal(cx, cy, cz, x, y, z) local chunk = ChunkManager:GetChunk(cx, cy, cz) if chunk and not chunk.loaded then ChunkManager:LoadChunk(cx, cy, cz) end if not chunk then return end local chunkKey = makeChunkKey(cx, cy, cz) local blockKey = makeBlockKey(x, y, z) if getPendingBreak(chunkKey, blockKey) then clearPendingBreak(chunkKey, blockKey) return end chunk:RemoveBlock(x, y, z) end function PlacementManager:GetBlockAtMouse(): nil | {chunk:Vector3, block: Vector3} pcall(function() PlacementManager:Raycast() end) local selectedPart = PlacementManager:RaycastGetResult() --print(selectedPart and selectedPart:GetFullName() or nil) if selectedPart == nil then clearSelection() script.RaycastResult.Value = nil debugPlacementLog("[PLACE][CLIENT][TARGET]", "no selectedPart after raycast", lastRaycastFailure) return nil end local chunkName, blockName = findChunkAndBlock(selectedPart) if not chunkName or not blockName then debugPlacementWarn( "[PLACE][CLIENT][TARGET]", "failed to find chunk/block from selection", selectedPart:GetFullName() ) return nil end local okChunk, chunkCoords = pcall(function() return Util.BlockPosStringToCoords(chunkName :: string) end) local okBlock, blockCoords = pcall(function() return Util.BlockPosStringToCoords(blockName :: string) end) if not okChunk or not okBlock then debugPlacementWarn( "[PLACE][CLIENT][TARGET]", "failed to parse names", "chunkName", chunkName, "blockName", blockName ) return nil end debugPlacementLog( "[PLACE][CLIENT][TARGET]", "chunk", chunkName, "block", blockName, "normal", (lastNormalId and lastNormalId.Name) or "nil" ) return { chunk = chunkCoords, block = blockCoords } end function PlacementManager:GetTargetAtMouse(): nil | {chunk:Vector3, block: Vector3, normal: Enum.NormalId} local hit = PlacementManager:GetBlockAtMouse() if not hit then return nil end local normal = lastNormalId or Enum.NormalId.Top return { chunk = hit.chunk, block = hit.block, normal = normal } end function PlacementManager:GetPlacementAtMouse(): nil | {chunk:Vector3, block: Vector3} local hit = PlacementManager:GetTargetAtMouse() if not hit then return nil end local offset = normalIdToOffset(hit.normal) local placeChunk, placeBlock = offsetChunkBlock(hit.chunk, hit.block, offset) debugPlacementLog( "[PLACE][CLIENT][PLACE_TARGET]", "target chunk", hit.chunk, "target block", hit.block, "normal", hit.normal.Name, "place chunk", placeChunk, "place block", placeBlock ) return { chunk = placeChunk, block = placeBlock } end function PlacementManager:DebugGetPlacementOrWarn() local placement = PlacementManager:GetPlacementAtMouse() if not placement then debugPlacementWarn("[PLACE][CLIENT][REJECT]", "no placement target under mouse", lastRaycastFailure) end return placement end function PlacementManager:Init() game:GetService("RunService").RenderStepped:Connect(function() local a,b = pcall(function() PlacementManager:Raycast() end) if not a then clearSelection("raycast error") script.RaycastResult.Value = nil end end) tickRemote.OnClientEvent:Connect(function(m, cx, cy, cz, x, y, z, d) --warn("PROPOGATED TICK", m, cx, cy, cz, x, y, z, d) if m == "B_C" then applyPlaceBlockLocal(cx, cy, cz, x, y ,z, d) end if m == "B_D" then applyBreakBlockLocal(cx, cy, cz, x, y ,z) end if m == "C_R" then clearPendingBreaksForChunk(makeChunkKey(cx, cy, cz)) ChunkManager:RefreshChunk(cx, cy, cz) end end) end return PlacementManager