Minecraft-like Roblox block game rblx.games/135624152691584
roblox roblox-game rojo

core: fix building

+590 -79
+13
src/ReplicatedStorage/Shared/ChunkManager/Chunk.lua
··· 196 196 end) 197 197 end 198 198 199 + function Chunk:UnloadImmediate() 200 + self.loaded = false 201 + pcall(function() 202 + self.unloadChunkHook() 203 + end) 204 + pcall(function() 205 + if self.instance then 206 + self.instance.Parent = nil 207 + self.instance:Destroy() 208 + end 209 + end) 210 + end 211 + 199 212 -- DO NOT INTERACT WITH CHUNK AFTER CALLING THIS 200 213 function Chunk:Destroy() 201 214 self.data = {}
+76 -27
src/ReplicatedStorage/Shared/ChunkManager/init.lua
··· 31 31 local unloadingChunks = {} 32 32 local pendingChunkRequests = {} 33 33 34 + local lastChunkKey: string? = nil 35 + local lastHeavyTick = 0 36 + local HEAVY_TICK_INTERVAL = 1.5 37 + local lastUnloadSweep = 0 38 + local UNLOAD_SWEEP_INTERVAL = 1.5 39 + 40 + local function worldToChunkCoord(v: number): number 41 + return math.floor((v + 16) / 32) 42 + end 43 + 34 44 local CHUNK_OFFSETS = {} 35 45 do 36 46 for y = -CHUNK_RADIUS, CHUNK_RADIUS do ··· 43 53 table.sort(CHUNK_OFFSETS, function(a, b) 44 54 return a[4] < b[4] 45 55 end) 56 + end 57 + 58 + function ChunkManager:UnloadAllNow() 59 + for key, chunk in pairs(Chunk.AllChunks) do 60 + unloadingChunks[key] = true 61 + pcall(function() 62 + if chunk.loaded then 63 + chunk:UnloadImmediate() 64 + end 65 + end) 66 + pcall(function() 67 + chunk:Destroy() 68 + end) 69 + Chunk.AllChunks[key] = nil 70 + unloadingChunks[key] = nil 71 + end 46 72 end 47 73 48 74 local function Swait(l) ··· 232 258 233 259 local pos = player.Character:GetPivot().Position 234 260 local chunkPos = { 235 - x = math.round(pos.X / 32), 236 - y = math.round(pos.Y / 32), 237 - z = math.round(pos.Z / 32) 261 + x = worldToChunkCoord(pos.X), 262 + y = worldToChunkCoord(pos.Y), 263 + z = worldToChunkCoord(pos.Z) 238 264 } 265 + local ck = `{chunkPos.x},{chunkPos.y},{chunkPos.z}` 266 + local now = tick() 267 + local shouldHeavyTick = (ck ~= lastChunkKey) or (now - lastHeavyTick >= HEAVY_TICK_INTERVAL) 268 + lastChunkKey = ck 269 + if shouldHeavyTick then 270 + lastHeavyTick = now 271 + end 239 272 240 - task.defer(function() 241 - local processed = 0 242 - for _, offset in ipairs(CHUNK_OFFSETS) do 243 - local cx, cy, cz = chunkPos.x + offset[1], chunkPos.y + offset[2], chunkPos.z + offset[3] 244 - local chunk = ChunkManager:GetChunk(cx, cy, cz) 245 - chunk.inhabitedTime = tick() 246 - if not chunk.loaded then 247 - ChunkManager:LoadChunk(cx, cy, cz) 248 - processed += 1 249 - if processed % LOAD_BATCH == 0 then 250 - Swait(1) 273 + if shouldHeavyTick then 274 + task.defer(function() 275 + local processed = 0 276 + for _, offset in ipairs(CHUNK_OFFSETS) do 277 + local cx, cy, cz = chunkPos.x + offset[1], chunkPos.y + offset[2], chunkPos.z + offset[3] 278 + local chunk = ChunkManager:GetChunk(cx, cy, cz) 279 + chunk.inhabitedTime = now 280 + if not chunk.loaded then 281 + ChunkManager:LoadChunk(cx, cy, cz) 282 + processed += 1 283 + if processed % LOAD_BATCH == 0 then 284 + Swait(1) 285 + end 251 286 end 252 287 end 288 + end) 289 + else 290 + local current = Chunk.AllChunks[ck] 291 + if current then 292 + current.inhabitedTime = now 253 293 end 254 - end) 294 + end 255 295 256 296 --[[ 257 297 task.defer(function() ··· 275 315 end) 276 316 --]] 277 317 278 - for key, loadedChunk in pairs(Chunk.AllChunks) do 279 - if tick() - loadedChunk.inhabitedTime > 15 and not unloadingChunks[key] then 280 - unloadingChunks[key] = true 281 - task.defer(function() 282 - loadedChunk:Unload() 283 - loadedChunk:Destroy() 284 - Chunk.AllChunks[key] = nil 285 - unloadingChunks[key] = nil 286 - end) 318 + if now - lastUnloadSweep >= UNLOAD_SWEEP_INTERVAL then 319 + lastUnloadSweep = now 320 + for key, loadedChunk in pairs(Chunk.AllChunks) do 321 + if now - loadedChunk.inhabitedTime > 15 and not unloadingChunks[key] then 322 + unloadingChunks[key] = true 323 + task.defer(function() 324 + loadedChunk:Unload() 325 + loadedChunk:Destroy() 326 + Chunk.AllChunks[key] = nil 327 + unloadingChunks[key] = nil 328 + end) 329 + end 287 330 end 288 331 end 289 332 end ··· 295 338 end 296 339 local pos = player.Character:GetPivot().Position 297 340 local chunkPos = { 298 - x = math.round(pos.X / 32), 299 - y = math.round(pos.Y / 32), 300 - z = math.round(pos.Z / 32) 341 + x = worldToChunkCoord(pos.X), 342 + y = worldToChunkCoord(pos.Y), 343 + z = worldToChunkCoord(pos.Z) 301 344 } 302 345 for y = -radius, radius do 303 346 for x = -radius, radius do ··· 326 369 327 370 ChunkFolder.Parent = game:GetService("Workspace") 328 371 ChunkManager:ForceTick() 372 + 373 + tickremote.OnClientEvent:Connect(function(m) 374 + if m == "U_ALL" then 375 + ChunkManager:UnloadAllNow() 376 + end 377 + end) 329 378 330 379 task.defer(function() 331 380 while true do
+306 -28
src/ReplicatedStorage/Shared/PlacementManager.lua
··· 6 6 local ChunkManager = require("./ChunkManager") 7 7 local Util = require("./Util") 8 8 9 + local DEBUG_PLACEMENT = true 10 + local function debugPlacementLog(...: any) 11 + if DEBUG_PLACEMENT then 12 + Util.StudioLog(...) 13 + end 14 + end 15 + 16 + local function debugPlacementWarn(...: any) 17 + if DEBUG_PLACEMENT then 18 + Util.StudioWarn(...) 19 + end 20 + end 21 + 9 22 PlacementManager.ChunkFolder = ChunkManager.ChunkFolder 10 23 11 24 local raycastParams = RaycastParams.new() ··· 13 26 raycastParams.FilterType = Enum.RaycastFilterType.Include 14 27 raycastParams.IgnoreWater = true 15 28 16 - if _G.SB then return nil end 17 - _G.SB = true 29 + if _G.__BLOCKSCRAFT_PLACEMENT_MANAGER then 30 + return _G.__BLOCKSCRAFT_PLACEMENT_MANAGER 31 + end 32 + _G.__BLOCKSCRAFT_PLACEMENT_MANAGER = PlacementManager 18 33 19 34 PlacementManager.SelectionBox = script.SelectionBox:Clone() 20 35 PlacementManager.SelectionBox.Name = "$SelectionBox"..(game:GetService("RunService"):IsServer() and "_SERVER" or "") 21 36 PlacementManager.SelectionBox.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain") 37 + PlacementManager.SelectionBox.Adornee = nil 22 38 23 39 -- Trash method TODO: Fix this 24 40 local function findChunkFolderFromDescendant(inst: Instance): Instance? ··· 27 43 if current.Parent == PlacementManager.ChunkFolder then 28 44 return current 29 45 end 46 + -- Fallback: match by name in case the ChunkFolder reference differs (e.g. recreated/parented later) 47 + if current.Parent:IsA("Folder") and current.Parent.Name == (PlacementManager.ChunkFolder and PlacementManager.ChunkFolder.Name) then 48 + return current 49 + end 30 50 current = current.Parent 31 51 end 32 52 return nil 33 53 end 34 54 55 + local function findBlockRoot(inst: Instance, chunkFolder: Instance): Instance? 56 + local current = inst 57 + while current and current ~= chunkFolder do 58 + if current:IsA("BasePart") then 59 + return current 60 + end 61 + current = current.Parent 62 + end 63 + return nil 64 + end 65 + 66 + local function resolveBlockInstance(chunkFolder: Instance, chunkName: string, blockName: string): Instance? 67 + local chunkInst = chunkFolder:FindFirstChild(chunkName) 68 + if not chunkInst then 69 + return nil 70 + end 71 + return chunkInst:FindFirstChild(blockName) 72 + end 73 + 74 + local function clearSelection(reason: string?) 75 + PlacementManager.SelectionBox.Adornee = nil 76 + PlacementManager.SelectionBox.Parent = nil 77 + lastNormalId = nil 78 + if reason then 79 + lastRaycastFailure = reason 80 + end 81 + end 82 + 83 + local function setSelection(target: Instance, parent: Instance) 84 + PlacementManager.SelectionBox.Parent = parent 85 + PlacementManager.SelectionBox.Adornee = target 86 + end 87 + 88 + local function findChunkAndBlock(inst: Instance): (string?, string?) 89 + local root = PlacementManager.ChunkFolder 90 + if not root then 91 + return nil, nil 92 + end 93 + local current = inst 94 + while current and current.Parent do 95 + -- case: current parent is the chunk folder root; then current is the chunk itself (no block name yet) 96 + if current.Parent == root then 97 + return current.Name, inst.Name 98 + end 99 + -- case: grandparent is chunk folder root; parent is chunk, current is block/model 100 + if current.Parent.Parent == root then 101 + return current.Parent.Name, current.Name 102 + end 103 + current = current.Parent 104 + end 105 + return nil, nil 106 + end 107 + 35 108 local Mouse: Mouse = nil 36 109 local lastNormalId: Enum.NormalId? = nil 37 110 local BREAK_ROLLBACK_TIMEOUT = 0.6 38 111 local pendingBreaks = {} 112 + local lastRaycastFailure: string? = nil 113 + local function vectorToNormalId(normal: Vector3): Enum.NormalId 114 + local ax, ay, az = math.abs(normal.X), math.abs(normal.Y), math.abs(normal.Z) 115 + if ax >= ay and ax >= az then 116 + return normal.X >= 0 and Enum.NormalId.Right or Enum.NormalId.Left 117 + elseif ay >= ax and ay >= az then 118 + return normal.Y >= 0 and Enum.NormalId.Top or Enum.NormalId.Bottom 119 + else 120 + return normal.Z >= 0 and Enum.NormalId.Back or Enum.NormalId.Front 121 + end 122 + end 39 123 40 124 local function makeChunkKey(cx: number, cy: number, cz: number): string 41 125 return `{cx},{cy},{cz}` ··· 150 234 return true 151 235 end 152 236 237 + local function ensureChunkFolder(): Instance? 238 + if PlacementManager.ChunkFolder and PlacementManager.ChunkFolder.Parent then 239 + return PlacementManager.ChunkFolder 240 + end 241 + local found = workspace:FindFirstChild("$blockscraft_client") 242 + if found then 243 + PlacementManager.ChunkFolder = found 244 + return found 245 + end 246 + return nil 247 + end 248 + 153 249 -- Gets the block and normalid of the block (and surface) the player is looking at 154 250 function PlacementManager:Raycast() 155 251 if not Mouse then 156 252 Mouse = game:GetService("Players").LocalPlayer:GetMouse() 157 253 end 158 - local objLookingAt = Mouse.Target 159 - local dir = Mouse.TargetSurface or Enum.NormalId.Top 254 + local chunkFolder = ensureChunkFolder() 255 + if not chunkFolder then 256 + clearSelection("chunk folder missing") 257 + script.RaycastResult.Value = nil 258 + return 259 + end 260 + 261 + raycastParams.FilterDescendantsInstances = {chunkFolder} 262 + local cam = workspace.CurrentCamera 263 + if not cam then 264 + lastRaycastFailure = "no camera" 265 + return 266 + end 267 + local ray = Mouse.UnitRay 268 + local result = workspace:Raycast(ray.Origin, ray.Direction * MAX_REACH, raycastParams) 269 + if not result then 270 + clearSelection("raycast miss") 271 + script.RaycastResult.Value = nil 272 + debugPlacementLog("[PLACE][CLIENT][RAYCAST]", "miss") 273 + return 274 + end 275 + 276 + local objLookingAt = result.Instance 160 277 if not objLookingAt then 161 - PlacementManager.SelectionBox.Adornee = nil 278 + clearSelection("raycast nil instance") 279 + script.RaycastResult.Value = nil 280 + debugPlacementWarn("[PLACE][CLIENT][RAYCAST]", "nil instance in result") 281 + return 282 + end 283 + 284 + local hitChunkFolder = findChunkFolderFromDescendant(objLookingAt) 285 + if not hitChunkFolder then 286 + debugPlacementWarn( 287 + "[PLACE][CLIENT][REJECT]", 288 + "target not in chunk folder", 289 + objLookingAt:GetFullName(), 290 + "parent", 291 + objLookingAt.Parent and objLookingAt.Parent:GetFullName() or "nil" 292 + ) 293 + clearSelection("target not in chunk folder") 162 294 script.RaycastResult.Value = nil 163 - lastNormalId = nil 164 295 return 165 296 end 166 - 167 - --if not objLookingAt:IsDescendantOf(ChunkManager.ChunkFolder) then return end 168 - local chunkFolder = findChunkFolderFromDescendant(objLookingAt) 169 - if not chunkFolder then 170 - PlacementManager.SelectionBox.Adornee = nil 297 + if hitChunkFolder:GetAttribute("ns") == true then 298 + debugPlacementWarn( 299 + "[PLACE][CLIENT][REJECT]", 300 + "chunk flagged ns", 301 + hitChunkFolder:GetFullName() 302 + ) 303 + clearSelection("target chunk marked ns") 171 304 script.RaycastResult.Value = nil 172 - lastNormalId = nil 173 305 return 174 306 end 175 - if chunkFolder:GetAttribute("ns") == true then 176 - PlacementManager.SelectionBox.Adornee = nil 307 + PlacementManager.ChunkFolder = chunkFolder 308 + 309 + local blockRoot = findBlockRoot(objLookingAt, chunkFolder) or objLookingAt 310 + local chunkName, blockName = findChunkAndBlock(blockRoot) 311 + if not chunkName or not blockName then 312 + clearSelection("failed to resolve chunk/block") 177 313 script.RaycastResult.Value = nil 178 - lastNormalId = nil 179 314 return 180 315 end 181 - PlacementManager.SelectionBox.Adornee = objLookingAt 316 + local okChunk, chunkCoords = pcall(function() 317 + return Util.BlockPosStringToCoords(chunkName) 318 + end) 319 + local okBlock, blockCoords = pcall(function() 320 + return Util.BlockPosStringToCoords(blockName) 321 + end) 322 + if not okChunk or not okBlock then 323 + clearSelection("failed to parse chunk/block names") 324 + script.RaycastResult.Value = nil 325 + return 326 + end 327 + 328 + -- hide selection if block no longer exists (air/removed) 329 + local chunk = ChunkManager:GetChunk(chunkCoords.X, chunkCoords.Y, chunkCoords.Z) 330 + local blockData = chunk and chunk:GetBlockAt(blockCoords.X, blockCoords.Y, blockCoords.Z) 331 + if not blockData or blockData == 0 or blockData.id == 0 then 332 + clearSelection("block missing/air") 333 + script.RaycastResult.Value = nil 334 + return 335 + end 336 + local blockInstance = resolveBlockInstance(chunkFolder, chunkName, blockName) or blockRoot 337 + if not blockInstance then 338 + clearSelection("missing block instance") 339 + script.RaycastResult.Value = nil 340 + return 341 + end 342 + 343 + lastRaycastFailure = nil 344 + setSelection(blockInstance, PlacementManager.ChunkFolder) 182 345 script.RaycastResult.Value = objLookingAt 183 - lastNormalId = dir 184 - return objLookingAt, dir 346 + lastNormalId = vectorToNormalId(result.Normal) 347 + debugPlacementLog( 348 + "[PLACE][CLIENT][RAYCAST][HIT]", 349 + blockInstance:GetFullName(), 350 + "chunkFolder", 351 + hitChunkFolder:GetFullName(), 352 + "blockName", 353 + blockInstance.Name, 354 + "normal", 355 + lastNormalId.Name 356 + ) 357 + return objLookingAt, lastNormalId 185 358 end 186 359 187 360 function PlacementManager:RaycastGetResult() ··· 195 368 196 369 -- FIRES REMOTE 197 370 function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockId: string) 371 + debugPlacementLog("[PLACE][CLIENT][PLACE_CALL]", "chunk", cx, cy, cz, "block", x, y, z, "blockId", blockId) 372 + if typeof(cx) ~= "number" or typeof(cy) ~= "number" or typeof(cz) ~= "number" then 373 + debugPlacementWarn("[PLACE][CLIENT][REJECT]", "chunk type", cx, cy, cz, x, y, z, blockId) 374 + return 375 + end 376 + if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then 377 + debugPlacementWarn("[PLACE][CLIENT][REJECT]", "block type", cx, cy, cz, x, y, z, blockId) 378 + return 379 + end 380 + if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then 381 + debugPlacementWarn("[PLACE][CLIENT][REJECT]", "block bounds", cx, cy, cz, x, y, z, blockId) 382 + return 383 + end 384 + if not isWithinReach(cx, cy, cz, x, y, z) then 385 + debugPlacementWarn("[PLACE][CLIENT][REJECT]", "reach", cx, cy, cz, x, y, z, blockId) 386 + return 387 + end 388 + 198 389 -- ensure chunk is present/rendered client-side 199 390 local chunk = ChunkManager:GetChunk(cx, cy, cz) 200 391 if chunk and not chunk.loaded then 201 392 ChunkManager:LoadChunk(cx, cy, cz) 202 393 end 394 + if not chunk then 395 + debugPlacementWarn("[PLACE][CLIENT][REJECT]", "missing chunk", cx, cy, cz, x, y, z, blockId) 396 + return 397 + end 203 398 204 399 -- if the client already thinks this block is the same id, skip sending 205 400 if chunk then 206 401 local existing = chunk:GetBlockAt(x, y, z) 207 402 local existingId = existing and existing.id 208 403 if existingId and tostring(existingId) == tostring(blockId) then 404 + debugPlacementLog( 405 + "[PLACE][CLIENT][SKIP]", 406 + "duplicate id", 407 + "chunk", 408 + cx, 409 + cy, 410 + cz, 411 + "block", 412 + x, 413 + y, 414 + z, 415 + "existingId", 416 + existingId, 417 + "blockId", 418 + blockId 419 + ) 209 420 return 421 + else 422 + debugPlacementLog( 423 + "[PLACE][CLIENT][EXISTING]", 424 + "chunk", 425 + cx, 426 + cy, 427 + cz, 428 + "block", 429 + x, 430 + y, 431 + z, 432 + "existingId", 433 + existingId 434 + ) 210 435 end 211 436 end 212 437 ··· 218 443 }) 219 444 end 220 445 446 + debugPlacementLog("[PLACE][CLIENT][SEND]", cx, cy, cz, x, y, z, blockId) 221 447 placeRemote:FireServer(cx, cy, cz, x, y, z, blockId) 222 448 end 223 449 ··· 227 453 return 228 454 end 229 455 if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then 456 + debugPlacementWarn("[BREAK][CLIENT][REJECT]", "block type", cx, cy, cz, x, y, z) 230 457 return 231 458 end 232 459 if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then 460 + debugPlacementWarn("[BREAK][CLIENT][REJECT]", "block bounds", cx, cy, cz, x, y, z) 233 461 return 234 462 end 235 463 if not isWithinReach(cx, cy, cz, x, y, z) then 464 + debugPlacementWarn("[BREAK][CLIENT][REJECT]", "reach", cx, cy, cz, x, y, z) 236 465 return 237 466 end 238 467 ··· 241 470 local chunkKey = makeChunkKey(cx, cy, cz) 242 471 local blockKey = makeBlockKey(x, y, z) 243 472 if getPendingBreak(chunkKey, blockKey) then 473 + debugPlacementLog("[BREAK][CLIENT][SKIP]", "pending rollback", cx, cy, cz, x, y, z) 244 474 return 245 475 end 246 476 pendingBreaks[chunkKey] = pendingBreaks[chunkKey] or {} ··· 252 482 chunk:RemoveBlock(x, y, z) 253 483 end 254 484 scheduleBreakRollback(cx, cy, cz, x, y, z) 485 + debugPlacementLog("[BREAK][CLIENT][SEND]", cx, cy, cz, x, y, z) 255 486 breakRemote:FireServer(cx, cy, cz, x, y, z) 256 487 end 257 488 ··· 283 514 end 284 515 285 516 function PlacementManager:GetBlockAtMouse(): nil | {chunk:Vector3, block: Vector3} 517 + pcall(function() 518 + PlacementManager:Raycast() 519 + end) 286 520 local selectedPart = PlacementManager:RaycastGetResult() 287 521 --print(selectedPart and selectedPart:GetFullName() or nil) 288 522 if selectedPart == nil then 289 523 PlacementManager.SelectionBox.Adornee = nil 290 524 script.RaycastResult.Value = nil 291 525 lastNormalId = nil 526 + debugPlacementLog("[PLACE][CLIENT][TARGET]", "no selectedPart after raycast", lastRaycastFailure) 292 527 return nil 293 528 end 294 - if not selectedPart.Parent then 295 - PlacementManager.SelectionBox.Adornee = nil 296 - script.RaycastResult.Value = nil 297 - lastNormalId = nil 529 + local chunkName, blockName = findChunkAndBlock(selectedPart) 530 + if not chunkName or not blockName then 531 + debugPlacementWarn( 532 + "[PLACE][CLIENT][TARGET]", 533 + "failed to find chunk/block from selection", 534 + selectedPart:GetFullName() 535 + ) 298 536 return nil 299 537 end 300 - if not selectedPart.Parent then 301 - PlacementManager.SelectionBox.Adornee = nil 302 - script.RaycastResult.Value = nil 303 - lastNormalId = nil 538 + 539 + local okChunk, chunkCoords = pcall(function() 540 + return Util.BlockPosStringToCoords(chunkName :: string) 541 + end) 542 + local okBlock, blockCoords = pcall(function() 543 + return Util.BlockPosStringToCoords(blockName :: string) 544 + end) 545 + if not okChunk or not okBlock then 546 + debugPlacementWarn( 547 + "[PLACE][CLIENT][TARGET]", 548 + "failed to parse names", 549 + "chunkName", 550 + chunkName, 551 + "blockName", 552 + blockName 553 + ) 304 554 return nil 305 555 end 306 - local chunkCoords = Util.BlockPosStringToCoords(selectedPart.Parent.Name) 307 - local blockCoords = Util.BlockPosStringToCoords(selectedPart.Name) 556 + debugPlacementLog( 557 + "[PLACE][CLIENT][TARGET]", 558 + "chunk", 559 + chunkName, 560 + "block", 561 + blockName, 562 + "normal", 563 + (lastNormalId and lastNormalId.Name) or "nil" 564 + ) 308 565 309 566 return { 310 567 chunk = chunkCoords, ··· 334 591 end 335 592 local offset = normalIdToOffset(hit.normal) 336 593 local placeChunk, placeBlock = offsetChunkBlock(hit.chunk, hit.block, offset) 594 + debugPlacementLog( 595 + "[PLACE][CLIENT][PLACE_TARGET]", 596 + "target chunk", 597 + hit.chunk, 598 + "target block", 599 + hit.block, 600 + "normal", 601 + hit.normal.Name, 602 + "place chunk", 603 + placeChunk, 604 + "place block", 605 + placeBlock 606 + ) 337 607 return { 338 608 chunk = placeChunk, 339 609 block = placeBlock 340 610 } 611 + end 612 + 613 + function PlacementManager:DebugGetPlacementOrWarn() 614 + local placement = PlacementManager:GetPlacementAtMouse() 615 + if not placement then 616 + debugPlacementWarn("[PLACE][CLIENT][REJECT]", "no placement target under mouse", lastRaycastFailure) 617 + end 618 + return placement 341 619 end 342 620 343 621 function PlacementManager:Init()
+2
src/ReplicatedStorage/Shared/PlacementState.lua
··· 23 23 selectedId = id or "" 24 24 selectedName = name or selectedId 25 25 valueObject.Value = selectedName or "" 26 + local Util = require(ReplicatedStorage:WaitForChild("Shared"):WaitForChild("Util")) 27 + Util.StudioLog("[PLACE][CLIENT][SELECT]", "id", selectedId, "name", selectedName) 26 28 changed:Fire(selectedId, selectedName) 27 29 end 28 30
+18
src/ReplicatedStorage/Shared/Util.lua
··· 1 + local RunService = game:GetService("RunService") 2 + local IS_STUDIO = RunService:IsStudio() 3 + 1 4 local module = {} 5 + 6 + -- Prints only when running in Studio (avoids noisy live logs) 7 + function module.StudioLog(...: any) 8 + if not IS_STUDIO then 9 + return 10 + end 11 + print(...) 12 + end 13 + 14 + function module.StudioWarn(...: any) 15 + if not IS_STUDIO then 16 + return 17 + end 18 + warn(...) 19 + end 2 20 3 21 function module.isNaN(n: number): boolean 4 22 -- NaN is never equal to itself
+45 -10
src/ServerScriptService/Actor/ServerChunkManager/TerrainGen/init.lua
··· 3 3 4 4 local TerrainGen = {} 5 5 6 - local deflate = require("./TerrainGen/Deflate") 7 - 8 - local DSS = game:GetService("DataStoreService") 9 - local WORLDNAME = "DEFAULT" 10 - local WORLDID = "b73bb5a6-297d-4352-b637-daec7e8c8f3e" 11 - local Store = DSS:GetDataStore("BlockscraftWorldV1", WORLDID) 12 - 13 6 local ChunkManager = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared").ChunkManager) 14 7 local Chunk = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared").ChunkManager.Chunk) 15 8 16 9 TerrainGen.ServerChunkCache = {} :: {[typeof("")]: typeof(Chunk.new(0,0,0))} 17 10 11 + local function chunkKeyFromCoords(x: number, y: number, z: number): string 12 + return `{x},{y},{z}` 13 + end 14 + 15 + function TerrainGen:UnloadAllChunks(): number 16 + local count = 0 17 + for key in pairs(TerrainGen.ServerChunkCache) do 18 + TerrainGen.ServerChunkCache[key] = nil 19 + count += 1 20 + end 21 + return count 22 + end 23 + 24 + local function worldToChunkCoord(v: number): number 25 + return math.floor((v + 16) / 32) 26 + end 27 + 28 + function TerrainGen:PreloadNearPlayers(radius: number, yRadius: number?): number 29 + local Players = game:GetService("Players") 30 + local r = radius or 5 31 + local ry = yRadius or 1 32 + local loaded = 0 33 + for _, player in ipairs(Players:GetPlayers()) do 34 + local character = player.Character 35 + local root = character and character:FindFirstChild("HumanoidRootPart") 36 + if root then 37 + local pos = root.Position 38 + local cx = worldToChunkCoord(pos.X) 39 + local cy = worldToChunkCoord(pos.Y) 40 + local cz = worldToChunkCoord(pos.Z) 41 + for y = -ry, ry do 42 + for x = -r, r do 43 + for z = -r, r do 44 + TerrainGen:GetChunk(cx + x, cy + y, cz + z) 45 + loaded += 1 46 + end 47 + end 48 + end 49 + end 50 + end 51 + return loaded 52 + end 53 + 18 54 -- Load a chunk from the DataStore or generate it if not found 19 55 function TerrainGen:GetChunk(x, y, z) 20 - local key = `{x},{y},{z}` 56 + local key = chunkKeyFromCoords(x, y, z) 21 57 if TerrainGen.ServerChunkCache[key] then 22 58 return TerrainGen.ServerChunkCache[key] 23 59 end 24 - 60 + 25 61 -- Generate a new chunk if it doesn't exist 26 62 local chunk = Chunk.new(x, y, z) 27 63 if y == 1 then ··· 66 102 67 103 return chunk 68 104 end 69 - 70 105 71 106 TerrainGen.CM = ChunkManager 72 107
+16 -9
src/ServerScriptService/Actor/ServerChunkManager/init.server.lua
··· 139 139 end 140 140 141 141 local DEBUG_PLACEMENT = true 142 + local function debugPlacementLog(...: any) 143 + if DEBUG_PLACEMENT then 144 + Util.StudioLog(...) 145 + end 146 + end 147 + 148 + local function debugPlacementWarn(...: any) 149 + if DEBUG_PLACEMENT then 150 + Util.StudioWarn(...) 151 + end 152 + end 142 153 143 154 placeRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockId) 144 155 local function reject(reason: string) 145 - if DEBUG_PLACEMENT then 146 - warn("[PLACE][REJECT]", player.Name, reason, "chunk", cx, cy, cz, "block", x, y, z, "id", blockId) 147 - end 156 + debugPlacementWarn("[PLACE][REJECT]", player.Name, reason, "chunk", cx, cy, cz, "block", x, y, z, "id", blockId) 148 157 return 149 158 end 150 159 ··· 178 187 if existing and existing.id and existing.id ~= 0 then 179 188 if existing.id == resolvedId then 180 189 -- same block already there; treat as success without changes 181 - if DEBUG_PLACEMENT then 182 - print("[PLACE][OK][NOOP]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId) 183 - end 190 + debugPlacementLog("[PLACE][OK][NOOP]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId) 184 191 return 185 192 end 186 193 -- allow replacement when different id: remove then place ··· 192 199 } 193 200 chunk:CreateBlock(x, y, z, data) 194 201 propogate("B_C", cx, cy, cz, x, y, z, data) 195 - if DEBUG_PLACEMENT then 196 - print("[PLACE][OK]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId) 197 - end 202 + debugPlacementLog("[PLACE][OK]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId) 198 203 end) 199 204 200 205 breakRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z) ··· 219 224 task.synchronize() 220 225 tickRemote:FireClient(player, "C_R", cx, cy, cz, 0, 0, 0, 0) 221 226 task.desynchronize() 227 + debugPlacementLog("[BREAK][NOOP]", player.Name, "chunk", cx, cy, cz, "block", x, y, z) 222 228 return 223 229 end 224 230 chunk:RemoveBlock(x, y, z) 225 231 propogate("B_D", cx, cy, cz, x, y, z, 0) 232 + debugPlacementLog("[BREAK][OK]", player.Name, "chunk", cx, cy, cz, "block", x, y, z) 226 233 end) 227 234 228 235 task.desynchronize()
+79
src/ServerScriptService/CmdrCommands/ChunkCullWorld.lua
··· 1 + return { 2 + Name = "chunkcull", 3 + Aliases = {"cullchunks", "resetchunks"}, 4 + Description = "Unload all server chunk cache instantly, then preload only chunks near players (and force clients to unload/resync).", 5 + Group = "Admin", 6 + Args = { 7 + { 8 + Type = "integer", 9 + Name = "radius", 10 + Description = "Horizontal chunk radius around each player to preload", 11 + Optional = true, 12 + Default = 5, 13 + }, 14 + { 15 + Type = "integer", 16 + Name = "yRadius", 17 + Description = "Vertical chunk radius around each player to preload", 18 + Optional = true, 19 + Default = 1, 20 + }, 21 + }, 22 + Run = function(context, radius, yRadius) 23 + local ReplicatedStorage = game:GetService("ReplicatedStorage") 24 + local Players = game:GetService("Players") 25 + 26 + local terrainGen = require( 27 + game:GetService("ServerScriptService") 28 + :WaitForChild("Actor") 29 + :WaitForChild("ServerChunkManager") 30 + :WaitForChild("TerrainGen") 31 + ) 32 + 33 + local tickRemote = ReplicatedStorage:WaitForChild("Tick") 34 + 35 + local r = radius or 5 36 + local ry = yRadius or 1 37 + 38 + local unloaded = 0 39 + pcall(function() 40 + unloaded = terrainGen:UnloadAllChunks() 41 + end) 42 + 43 + -- Tell all clients to immediately drop their local chunk instances 44 + pcall(function() 45 + tickRemote:FireAllClients("U_ALL", 0, 0, 0, 0, 0, 0, 0) 46 + end) 47 + 48 + -- Preload server chunks around players (reduces initial lag spikes after cull) 49 + local preloaded = 0 50 + pcall(function() 51 + preloaded = terrainGen:PreloadNearPlayers(r, ry) 52 + end) 53 + 54 + -- Force clients to resync around themselves 55 + local resyncCount = 0 56 + for _, player in ipairs(Players:GetPlayers()) do 57 + local character = player.Character 58 + local root = character and character:FindFirstChild("HumanoidRootPart") 59 + if root then 60 + local pos = root.Position 61 + local cx = math.floor((pos.X + 16) / 32) 62 + local cy = math.floor((pos.Y + 16) / 32) 63 + local cz = math.floor((pos.Z + 16) / 32) 64 + for y = -ry, ry do 65 + for x = -r, r do 66 + for z = -r, r do 67 + tickRemote:FireClient(player, "C_R", cx + x, cy + y, cz + z, 0, 0, 0, 0) 68 + resyncCount += 1 69 + end 70 + end 71 + end 72 + end 73 + end 74 + 75 + return ( 76 + "chunkcull done | unloaded=%d | preloaded=%d | resyncPackets=%d | radius=%d yRadius=%d" 77 + ):format(unloaded, preloaded, resyncCount, r, ry) 78 + end, 79 + }
+35 -5
src/StarterGui/Hotbar/LocalScript.client.lua
··· 15 15 local PM = require(ReplicatedStorage.Shared.PlacementManager) 16 16 local BlockManager = require(ReplicatedStorage.Shared.ChunkManager.BlockManager) 17 17 local PlacementState = require(ReplicatedStorage.Shared.PlacementState) 18 + local Util = require(ReplicatedStorage.Shared.Util) 18 19 19 20 local blocksFolder = ReplicatedStorage:WaitForChild("Blocks") 20 21 ··· 56 57 for _, block in ipairs(blocksFolder:GetChildren()) do 57 58 local id = block:GetAttribute("n") 58 59 if id ~= nil then 59 - local idStr = tostring(id) 60 - table.insert(ids, idStr) 61 - names[idStr] = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name 60 + local n = tonumber(id) 61 + if n and n > 0 then 62 + local idStr = tostring(n) 63 + table.insert(ids, idStr) 64 + names[idStr] = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name 65 + end 62 66 end 63 67 end 64 68 table.sort(ids, function(a, b) ··· 133 137 local slots, names = buildHotbarIds() 134 138 self.state.slots = slots 135 139 self.state.names = names 140 + local initialId = slots and slots[1] or "" 141 + if initialId and initialId ~= "" then 142 + local initialName = names and (names[initialId] or initialId) or initialId 143 + PlacementState:SetSelected(initialId, initialName) 144 + end 136 145 137 146 self._updateSlots = function() 138 147 local nextSlots, nextNames = buildHotbarIds() ··· 154 163 if id ~= "" and self.state.names then 155 164 name = self.state.names[id] or id 156 165 end 166 + Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", id, "name", name) 157 167 PlacementState:SetSelected(id, name) 158 168 end 159 169 160 170 self._handleInput = function(input: InputObject, gameProcessedEvent: boolean) 161 - if gameProcessedEvent or isTextInputFocused() then 171 + if isTextInputFocused() then 162 172 return 163 173 end 164 174 165 175 local slot = keyToSlot[input.KeyCode] 166 176 if slot then 177 + if gameProcessedEvent then 178 + return 179 + end 167 180 self._setSelected(slot) 168 181 return 169 182 end 170 183 171 184 if input.UserInputType == Enum.UserInputType.MouseButton1 then 185 + Util.StudioLog("[INPUT][CLIENT]", "MouseButton1", "processed", gameProcessedEvent) 186 + -- Allow click even if gameProcessedEvent (UI can set this), but only if we're actually pointing at a block 187 + if not PM:GetBlockAtMouse() then 188 + return 189 + end 172 190 local mouseBlock = PM:GetBlockAtMouse() 173 191 if not mouseBlock then 174 192 return ··· 182 200 mouseBlock.block.Z 183 201 ) 184 202 elseif input.UserInputType == Enum.UserInputType.MouseButton2 then 185 - local mouseBlock = PM:GetPlacementAtMouse() 203 + Util.StudioLog("[INPUT][CLIENT]", "MouseButton2", "processed", gameProcessedEvent) 204 + -- Allow click even if gameProcessedEvent (UI can set this), but only if we're actually pointing at a block 205 + local mouseBlock = PM:DebugGetPlacementOrWarn() 186 206 if not mouseBlock then 187 207 return 188 208 end 189 209 local id = PlacementState:GetSelected() 190 210 if not id or id == "" then 211 + Util.StudioWarn("[PLACE][CLIENT][REJECT]", "no selected id") 191 212 return 192 213 end 214 + Util.StudioLog( 215 + "[PLACE][CLIENT][SEND][CLICK]", 216 + "chunk", 217 + mouseBlock.chunk, 218 + "block", 219 + mouseBlock.block, 220 + "id", 221 + id 222 + ) 193 223 PM:PlaceBlock( 194 224 mouseBlock.chunk.X, 195 225 mouseBlock.chunk.Y,