···3131local unloadingChunks = {}
3232local pendingChunkRequests = {}
33333434+local lastChunkKey: string? = nil
3535+local lastHeavyTick = 0
3636+local HEAVY_TICK_INTERVAL = 1.5
3737+local lastUnloadSweep = 0
3838+local UNLOAD_SWEEP_INTERVAL = 1.5
3939+4040+local function worldToChunkCoord(v: number): number
4141+ return math.floor((v + 16) / 32)
4242+end
4343+3444local CHUNK_OFFSETS = {}
3545do
3646 for y = -CHUNK_RADIUS, CHUNK_RADIUS do
···4353 table.sort(CHUNK_OFFSETS, function(a, b)
4454 return a[4] < b[4]
4555 end)
5656+end
5757+5858+function ChunkManager:UnloadAllNow()
5959+ for key, chunk in pairs(Chunk.AllChunks) do
6060+ unloadingChunks[key] = true
6161+ pcall(function()
6262+ if chunk.loaded then
6363+ chunk:UnloadImmediate()
6464+ end
6565+ end)
6666+ pcall(function()
6767+ chunk:Destroy()
6868+ end)
6969+ Chunk.AllChunks[key] = nil
7070+ unloadingChunks[key] = nil
7171+ end
4672end
47734874local function Swait(l)
···232258233259 local pos = player.Character:GetPivot().Position
234260 local chunkPos = {
235235- x = math.round(pos.X / 32),
236236- y = math.round(pos.Y / 32),
237237- z = math.round(pos.Z / 32)
261261+ x = worldToChunkCoord(pos.X),
262262+ y = worldToChunkCoord(pos.Y),
263263+ z = worldToChunkCoord(pos.Z)
238264 }
265265+ local ck = `{chunkPos.x},{chunkPos.y},{chunkPos.z}`
266266+ local now = tick()
267267+ local shouldHeavyTick = (ck ~= lastChunkKey) or (now - lastHeavyTick >= HEAVY_TICK_INTERVAL)
268268+ lastChunkKey = ck
269269+ if shouldHeavyTick then
270270+ lastHeavyTick = now
271271+ end
239272240240- task.defer(function()
241241- local processed = 0
242242- for _, offset in ipairs(CHUNK_OFFSETS) do
243243- local cx, cy, cz = chunkPos.x + offset[1], chunkPos.y + offset[2], chunkPos.z + offset[3]
244244- local chunk = ChunkManager:GetChunk(cx, cy, cz)
245245- chunk.inhabitedTime = tick()
246246- if not chunk.loaded then
247247- ChunkManager:LoadChunk(cx, cy, cz)
248248- processed += 1
249249- if processed % LOAD_BATCH == 0 then
250250- Swait(1)
273273+ if shouldHeavyTick then
274274+ task.defer(function()
275275+ local processed = 0
276276+ for _, offset in ipairs(CHUNK_OFFSETS) do
277277+ local cx, cy, cz = chunkPos.x + offset[1], chunkPos.y + offset[2], chunkPos.z + offset[3]
278278+ local chunk = ChunkManager:GetChunk(cx, cy, cz)
279279+ chunk.inhabitedTime = now
280280+ if not chunk.loaded then
281281+ ChunkManager:LoadChunk(cx, cy, cz)
282282+ processed += 1
283283+ if processed % LOAD_BATCH == 0 then
284284+ Swait(1)
285285+ end
251286 end
252287 end
288288+ end)
289289+ else
290290+ local current = Chunk.AllChunks[ck]
291291+ if current then
292292+ current.inhabitedTime = now
253293 end
254254- end)
294294+ end
255295256296 --[[
257297 task.defer(function()
···275315 end)
276316 --]]
277317278278- for key, loadedChunk in pairs(Chunk.AllChunks) do
279279- if tick() - loadedChunk.inhabitedTime > 15 and not unloadingChunks[key] then
280280- unloadingChunks[key] = true
281281- task.defer(function()
282282- loadedChunk:Unload()
283283- loadedChunk:Destroy()
284284- Chunk.AllChunks[key] = nil
285285- unloadingChunks[key] = nil
286286- end)
318318+ if now - lastUnloadSweep >= UNLOAD_SWEEP_INTERVAL then
319319+ lastUnloadSweep = now
320320+ for key, loadedChunk in pairs(Chunk.AllChunks) do
321321+ if now - loadedChunk.inhabitedTime > 15 and not unloadingChunks[key] then
322322+ unloadingChunks[key] = true
323323+ task.defer(function()
324324+ loadedChunk:Unload()
325325+ loadedChunk:Destroy()
326326+ Chunk.AllChunks[key] = nil
327327+ unloadingChunks[key] = nil
328328+ end)
329329+ end
287330 end
288331 end
289332end
···295338 end
296339 local pos = player.Character:GetPivot().Position
297340 local chunkPos = {
298298- x = math.round(pos.X / 32),
299299- y = math.round(pos.Y / 32),
300300- z = math.round(pos.Z / 32)
341341+ x = worldToChunkCoord(pos.X),
342342+ y = worldToChunkCoord(pos.Y),
343343+ z = worldToChunkCoord(pos.Z)
301344 }
302345 for y = -radius, radius do
303346 for x = -radius, radius do
···326369327370 ChunkFolder.Parent = game:GetService("Workspace")
328371 ChunkManager:ForceTick()
372372+373373+ tickremote.OnClientEvent:Connect(function(m)
374374+ if m == "U_ALL" then
375375+ ChunkManager:UnloadAllNow()
376376+ end
377377+ end)
329378330379 task.defer(function()
331380 while true do
+306-28
src/ReplicatedStorage/Shared/PlacementManager.lua
···66local ChunkManager = require("./ChunkManager")
77local Util = require("./Util")
8899+local DEBUG_PLACEMENT = true
1010+local function debugPlacementLog(...: any)
1111+ if DEBUG_PLACEMENT then
1212+ Util.StudioLog(...)
1313+ end
1414+end
1515+1616+local function debugPlacementWarn(...: any)
1717+ if DEBUG_PLACEMENT then
1818+ Util.StudioWarn(...)
1919+ end
2020+end
2121+922PlacementManager.ChunkFolder = ChunkManager.ChunkFolder
10231124local raycastParams = RaycastParams.new()
···1326raycastParams.FilterType = Enum.RaycastFilterType.Include
1427raycastParams.IgnoreWater = true
15281616-if _G.SB then return nil end
1717-_G.SB = true
2929+if _G.__BLOCKSCRAFT_PLACEMENT_MANAGER then
3030+ return _G.__BLOCKSCRAFT_PLACEMENT_MANAGER
3131+end
3232+_G.__BLOCKSCRAFT_PLACEMENT_MANAGER = PlacementManager
18331934PlacementManager.SelectionBox = script.SelectionBox:Clone()
2035PlacementManager.SelectionBox.Name = "$SelectionBox"..(game:GetService("RunService"):IsServer() and "_SERVER" or "")
2136PlacementManager.SelectionBox.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain")
3737+PlacementManager.SelectionBox.Adornee = nil
22382339-- Trash method TODO: Fix this
2440local function findChunkFolderFromDescendant(inst: Instance): Instance?
···2743 if current.Parent == PlacementManager.ChunkFolder then
2844 return current
2945 end
4646+ -- Fallback: match by name in case the ChunkFolder reference differs (e.g. recreated/parented later)
4747+ if current.Parent:IsA("Folder") and current.Parent.Name == (PlacementManager.ChunkFolder and PlacementManager.ChunkFolder.Name) then
4848+ return current
4949+ end
3050 current = current.Parent
3151 end
3252 return nil
3353end
34545555+local function findBlockRoot(inst: Instance, chunkFolder: Instance): Instance?
5656+ local current = inst
5757+ while current and current ~= chunkFolder do
5858+ if current:IsA("BasePart") then
5959+ return current
6060+ end
6161+ current = current.Parent
6262+ end
6363+ return nil
6464+end
6565+6666+local function resolveBlockInstance(chunkFolder: Instance, chunkName: string, blockName: string): Instance?
6767+ local chunkInst = chunkFolder:FindFirstChild(chunkName)
6868+ if not chunkInst then
6969+ return nil
7070+ end
7171+ return chunkInst:FindFirstChild(blockName)
7272+end
7373+7474+local function clearSelection(reason: string?)
7575+ PlacementManager.SelectionBox.Adornee = nil
7676+ PlacementManager.SelectionBox.Parent = nil
7777+ lastNormalId = nil
7878+ if reason then
7979+ lastRaycastFailure = reason
8080+ end
8181+end
8282+8383+local function setSelection(target: Instance, parent: Instance)
8484+ PlacementManager.SelectionBox.Parent = parent
8585+ PlacementManager.SelectionBox.Adornee = target
8686+end
8787+8888+local function findChunkAndBlock(inst: Instance): (string?, string?)
8989+ local root = PlacementManager.ChunkFolder
9090+ if not root then
9191+ return nil, nil
9292+ end
9393+ local current = inst
9494+ while current and current.Parent do
9595+ -- case: current parent is the chunk folder root; then current is the chunk itself (no block name yet)
9696+ if current.Parent == root then
9797+ return current.Name, inst.Name
9898+ end
9999+ -- case: grandparent is chunk folder root; parent is chunk, current is block/model
100100+ if current.Parent.Parent == root then
101101+ return current.Parent.Name, current.Name
102102+ end
103103+ current = current.Parent
104104+ end
105105+ return nil, nil
106106+end
107107+35108local Mouse: Mouse = nil
36109local lastNormalId: Enum.NormalId? = nil
37110local BREAK_ROLLBACK_TIMEOUT = 0.6
38111local pendingBreaks = {}
112112+local lastRaycastFailure: string? = nil
113113+local function vectorToNormalId(normal: Vector3): Enum.NormalId
114114+ local ax, ay, az = math.abs(normal.X), math.abs(normal.Y), math.abs(normal.Z)
115115+ if ax >= ay and ax >= az then
116116+ return normal.X >= 0 and Enum.NormalId.Right or Enum.NormalId.Left
117117+ elseif ay >= ax and ay >= az then
118118+ return normal.Y >= 0 and Enum.NormalId.Top or Enum.NormalId.Bottom
119119+ else
120120+ return normal.Z >= 0 and Enum.NormalId.Back or Enum.NormalId.Front
121121+ end
122122+end
3912340124local function makeChunkKey(cx: number, cy: number, cz: number): string
41125 return `{cx},{cy},{cz}`
···150234 return true
151235end
152236237237+local function ensureChunkFolder(): Instance?
238238+ if PlacementManager.ChunkFolder and PlacementManager.ChunkFolder.Parent then
239239+ return PlacementManager.ChunkFolder
240240+ end
241241+ local found = workspace:FindFirstChild("$blockscraft_client")
242242+ if found then
243243+ PlacementManager.ChunkFolder = found
244244+ return found
245245+ end
246246+ return nil
247247+end
248248+153249-- Gets the block and normalid of the block (and surface) the player is looking at
154250function PlacementManager:Raycast()
155251 if not Mouse then
156252 Mouse = game:GetService("Players").LocalPlayer:GetMouse()
157253 end
158158- local objLookingAt = Mouse.Target
159159- local dir = Mouse.TargetSurface or Enum.NormalId.Top
254254+ local chunkFolder = ensureChunkFolder()
255255+ if not chunkFolder then
256256+ clearSelection("chunk folder missing")
257257+ script.RaycastResult.Value = nil
258258+ return
259259+ end
260260+261261+ raycastParams.FilterDescendantsInstances = {chunkFolder}
262262+ local cam = workspace.CurrentCamera
263263+ if not cam then
264264+ lastRaycastFailure = "no camera"
265265+ return
266266+ end
267267+ local ray = Mouse.UnitRay
268268+ local result = workspace:Raycast(ray.Origin, ray.Direction * MAX_REACH, raycastParams)
269269+ if not result then
270270+ clearSelection("raycast miss")
271271+ script.RaycastResult.Value = nil
272272+ debugPlacementLog("[PLACE][CLIENT][RAYCAST]", "miss")
273273+ return
274274+ end
275275+276276+ local objLookingAt = result.Instance
160277 if not objLookingAt then
161161- PlacementManager.SelectionBox.Adornee = nil
278278+ clearSelection("raycast nil instance")
279279+ script.RaycastResult.Value = nil
280280+ debugPlacementWarn("[PLACE][CLIENT][RAYCAST]", "nil instance in result")
281281+ return
282282+ end
283283+284284+ local hitChunkFolder = findChunkFolderFromDescendant(objLookingAt)
285285+ if not hitChunkFolder then
286286+ debugPlacementWarn(
287287+ "[PLACE][CLIENT][REJECT]",
288288+ "target not in chunk folder",
289289+ objLookingAt:GetFullName(),
290290+ "parent",
291291+ objLookingAt.Parent and objLookingAt.Parent:GetFullName() or "nil"
292292+ )
293293+ clearSelection("target not in chunk folder")
162294 script.RaycastResult.Value = nil
163163- lastNormalId = nil
164295 return
165296 end
166166-167167- --if not objLookingAt:IsDescendantOf(ChunkManager.ChunkFolder) then return end
168168- local chunkFolder = findChunkFolderFromDescendant(objLookingAt)
169169- if not chunkFolder then
170170- PlacementManager.SelectionBox.Adornee = nil
297297+ if hitChunkFolder:GetAttribute("ns") == true then
298298+ debugPlacementWarn(
299299+ "[PLACE][CLIENT][REJECT]",
300300+ "chunk flagged ns",
301301+ hitChunkFolder:GetFullName()
302302+ )
303303+ clearSelection("target chunk marked ns")
171304 script.RaycastResult.Value = nil
172172- lastNormalId = nil
173305 return
174306 end
175175- if chunkFolder:GetAttribute("ns") == true then
176176- PlacementManager.SelectionBox.Adornee = nil
307307+ PlacementManager.ChunkFolder = chunkFolder
308308+309309+ local blockRoot = findBlockRoot(objLookingAt, chunkFolder) or objLookingAt
310310+ local chunkName, blockName = findChunkAndBlock(blockRoot)
311311+ if not chunkName or not blockName then
312312+ clearSelection("failed to resolve chunk/block")
177313 script.RaycastResult.Value = nil
178178- lastNormalId = nil
179314 return
180315 end
181181- PlacementManager.SelectionBox.Adornee = objLookingAt
316316+ local okChunk, chunkCoords = pcall(function()
317317+ return Util.BlockPosStringToCoords(chunkName)
318318+ end)
319319+ local okBlock, blockCoords = pcall(function()
320320+ return Util.BlockPosStringToCoords(blockName)
321321+ end)
322322+ if not okChunk or not okBlock then
323323+ clearSelection("failed to parse chunk/block names")
324324+ script.RaycastResult.Value = nil
325325+ return
326326+ end
327327+328328+ -- hide selection if block no longer exists (air/removed)
329329+ local chunk = ChunkManager:GetChunk(chunkCoords.X, chunkCoords.Y, chunkCoords.Z)
330330+ local blockData = chunk and chunk:GetBlockAt(blockCoords.X, blockCoords.Y, blockCoords.Z)
331331+ if not blockData or blockData == 0 or blockData.id == 0 then
332332+ clearSelection("block missing/air")
333333+ script.RaycastResult.Value = nil
334334+ return
335335+ end
336336+ local blockInstance = resolveBlockInstance(chunkFolder, chunkName, blockName) or blockRoot
337337+ if not blockInstance then
338338+ clearSelection("missing block instance")
339339+ script.RaycastResult.Value = nil
340340+ return
341341+ end
342342+343343+ lastRaycastFailure = nil
344344+ setSelection(blockInstance, PlacementManager.ChunkFolder)
182345 script.RaycastResult.Value = objLookingAt
183183- lastNormalId = dir
184184- return objLookingAt, dir
346346+ lastNormalId = vectorToNormalId(result.Normal)
347347+ debugPlacementLog(
348348+ "[PLACE][CLIENT][RAYCAST][HIT]",
349349+ blockInstance:GetFullName(),
350350+ "chunkFolder",
351351+ hitChunkFolder:GetFullName(),
352352+ "blockName",
353353+ blockInstance.Name,
354354+ "normal",
355355+ lastNormalId.Name
356356+ )
357357+ return objLookingAt, lastNormalId
185358end
186359187360function PlacementManager:RaycastGetResult()
···195368196369-- FIRES REMOTE
197370function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockId: string)
371371+ debugPlacementLog("[PLACE][CLIENT][PLACE_CALL]", "chunk", cx, cy, cz, "block", x, y, z, "blockId", blockId)
372372+ if typeof(cx) ~= "number" or typeof(cy) ~= "number" or typeof(cz) ~= "number" then
373373+ debugPlacementWarn("[PLACE][CLIENT][REJECT]", "chunk type", cx, cy, cz, x, y, z, blockId)
374374+ return
375375+ end
376376+ if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then
377377+ debugPlacementWarn("[PLACE][CLIENT][REJECT]", "block type", cx, cy, cz, x, y, z, blockId)
378378+ return
379379+ end
380380+ if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then
381381+ debugPlacementWarn("[PLACE][CLIENT][REJECT]", "block bounds", cx, cy, cz, x, y, z, blockId)
382382+ return
383383+ end
384384+ if not isWithinReach(cx, cy, cz, x, y, z) then
385385+ debugPlacementWarn("[PLACE][CLIENT][REJECT]", "reach", cx, cy, cz, x, y, z, blockId)
386386+ return
387387+ end
388388+198389 -- ensure chunk is present/rendered client-side
199390 local chunk = ChunkManager:GetChunk(cx, cy, cz)
200391 if chunk and not chunk.loaded then
201392 ChunkManager:LoadChunk(cx, cy, cz)
202393 end
394394+ if not chunk then
395395+ debugPlacementWarn("[PLACE][CLIENT][REJECT]", "missing chunk", cx, cy, cz, x, y, z, blockId)
396396+ return
397397+ end
203398204399 -- if the client already thinks this block is the same id, skip sending
205400 if chunk then
206401 local existing = chunk:GetBlockAt(x, y, z)
207402 local existingId = existing and existing.id
208403 if existingId and tostring(existingId) == tostring(blockId) then
404404+ debugPlacementLog(
405405+ "[PLACE][CLIENT][SKIP]",
406406+ "duplicate id",
407407+ "chunk",
408408+ cx,
409409+ cy,
410410+ cz,
411411+ "block",
412412+ x,
413413+ y,
414414+ z,
415415+ "existingId",
416416+ existingId,
417417+ "blockId",
418418+ blockId
419419+ )
209420 return
421421+ else
422422+ debugPlacementLog(
423423+ "[PLACE][CLIENT][EXISTING]",
424424+ "chunk",
425425+ cx,
426426+ cy,
427427+ cz,
428428+ "block",
429429+ x,
430430+ y,
431431+ z,
432432+ "existingId",
433433+ existingId
434434+ )
210435 end
211436 end
212437···218443 })
219444 end
220445446446+ debugPlacementLog("[PLACE][CLIENT][SEND]", cx, cy, cz, x, y, z, blockId)
221447 placeRemote:FireServer(cx, cy, cz, x, y, z, blockId)
222448end
223449···227453 return
228454 end
229455 if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then
456456+ debugPlacementWarn("[BREAK][CLIENT][REJECT]", "block type", cx, cy, cz, x, y, z)
230457 return
231458 end
232459 if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then
460460+ debugPlacementWarn("[BREAK][CLIENT][REJECT]", "block bounds", cx, cy, cz, x, y, z)
233461 return
234462 end
235463 if not isWithinReach(cx, cy, cz, x, y, z) then
464464+ debugPlacementWarn("[BREAK][CLIENT][REJECT]", "reach", cx, cy, cz, x, y, z)
236465 return
237466 end
238467···241470 local chunkKey = makeChunkKey(cx, cy, cz)
242471 local blockKey = makeBlockKey(x, y, z)
243472 if getPendingBreak(chunkKey, blockKey) then
473473+ debugPlacementLog("[BREAK][CLIENT][SKIP]", "pending rollback", cx, cy, cz, x, y, z)
244474 return
245475 end
246476 pendingBreaks[chunkKey] = pendingBreaks[chunkKey] or {}
···252482 chunk:RemoveBlock(x, y, z)
253483 end
254484 scheduleBreakRollback(cx, cy, cz, x, y, z)
485485+ debugPlacementLog("[BREAK][CLIENT][SEND]", cx, cy, cz, x, y, z)
255486 breakRemote:FireServer(cx, cy, cz, x, y, z)
256487end
257488···283514end
284515285516function PlacementManager:GetBlockAtMouse(): nil | {chunk:Vector3, block: Vector3}
517517+ pcall(function()
518518+ PlacementManager:Raycast()
519519+ end)
286520 local selectedPart = PlacementManager:RaycastGetResult()
287521 --print(selectedPart and selectedPart:GetFullName() or nil)
288522 if selectedPart == nil then
289523 PlacementManager.SelectionBox.Adornee = nil
290524 script.RaycastResult.Value = nil
291525 lastNormalId = nil
526526+ debugPlacementLog("[PLACE][CLIENT][TARGET]", "no selectedPart after raycast", lastRaycastFailure)
292527 return nil
293528 end
294294- if not selectedPart.Parent then
295295- PlacementManager.SelectionBox.Adornee = nil
296296- script.RaycastResult.Value = nil
297297- lastNormalId = nil
529529+ local chunkName, blockName = findChunkAndBlock(selectedPart)
530530+ if not chunkName or not blockName then
531531+ debugPlacementWarn(
532532+ "[PLACE][CLIENT][TARGET]",
533533+ "failed to find chunk/block from selection",
534534+ selectedPart:GetFullName()
535535+ )
298536 return nil
299537 end
300300- if not selectedPart.Parent then
301301- PlacementManager.SelectionBox.Adornee = nil
302302- script.RaycastResult.Value = nil
303303- lastNormalId = nil
538538+539539+ local okChunk, chunkCoords = pcall(function()
540540+ return Util.BlockPosStringToCoords(chunkName :: string)
541541+ end)
542542+ local okBlock, blockCoords = pcall(function()
543543+ return Util.BlockPosStringToCoords(blockName :: string)
544544+ end)
545545+ if not okChunk or not okBlock then
546546+ debugPlacementWarn(
547547+ "[PLACE][CLIENT][TARGET]",
548548+ "failed to parse names",
549549+ "chunkName",
550550+ chunkName,
551551+ "blockName",
552552+ blockName
553553+ )
304554 return nil
305555 end
306306- local chunkCoords = Util.BlockPosStringToCoords(selectedPart.Parent.Name)
307307- local blockCoords = Util.BlockPosStringToCoords(selectedPart.Name)
556556+ debugPlacementLog(
557557+ "[PLACE][CLIENT][TARGET]",
558558+ "chunk",
559559+ chunkName,
560560+ "block",
561561+ blockName,
562562+ "normal",
563563+ (lastNormalId and lastNormalId.Name) or "nil"
564564+ )
308565309566 return {
310567 chunk = chunkCoords,
···334591 end
335592 local offset = normalIdToOffset(hit.normal)
336593 local placeChunk, placeBlock = offsetChunkBlock(hit.chunk, hit.block, offset)
594594+ debugPlacementLog(
595595+ "[PLACE][CLIENT][PLACE_TARGET]",
596596+ "target chunk",
597597+ hit.chunk,
598598+ "target block",
599599+ hit.block,
600600+ "normal",
601601+ hit.normal.Name,
602602+ "place chunk",
603603+ placeChunk,
604604+ "place block",
605605+ placeBlock
606606+ )
337607 return {
338608 chunk = placeChunk,
339609 block = placeBlock
340610 }
611611+end
612612+613613+function PlacementManager:DebugGetPlacementOrWarn()
614614+ local placement = PlacementManager:GetPlacementAtMouse()
615615+ if not placement then
616616+ debugPlacementWarn("[PLACE][CLIENT][REJECT]", "no placement target under mouse", lastRaycastFailure)
617617+ end
618618+ return placement
341619end
342620343621function PlacementManager:Init()
+2
src/ReplicatedStorage/Shared/PlacementState.lua
···2323 selectedId = id or ""
2424 selectedName = name or selectedId
2525 valueObject.Value = selectedName or ""
2626+ local Util = require(ReplicatedStorage:WaitForChild("Shared"):WaitForChild("Util"))
2727+ Util.StudioLog("[PLACE][CLIENT][SELECT]", "id", selectedId, "name", selectedName)
2628 changed:Fire(selectedId, selectedName)
2729end
2830
+18
src/ReplicatedStorage/Shared/Util.lua
···11+local RunService = game:GetService("RunService")
22+local IS_STUDIO = RunService:IsStudio()
33+14local module = {}
55+66+-- Prints only when running in Studio (avoids noisy live logs)
77+function module.StudioLog(...: any)
88+ if not IS_STUDIO then
99+ return
1010+ end
1111+ print(...)
1212+end
1313+1414+function module.StudioWarn(...: any)
1515+ if not IS_STUDIO then
1616+ return
1717+ end
1818+ warn(...)
1919+end
220321function module.isNaN(n: number): boolean
422 -- NaN is never equal to itself
···3344local TerrainGen = {}
5566-local deflate = require("./TerrainGen/Deflate")
77-88-local DSS = game:GetService("DataStoreService")
99-local WORLDNAME = "DEFAULT"
1010-local WORLDID = "b73bb5a6-297d-4352-b637-daec7e8c8f3e"
1111-local Store = DSS:GetDataStore("BlockscraftWorldV1", WORLDID)
1212-136local ChunkManager = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared").ChunkManager)
147local Chunk = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared").ChunkManager.Chunk)
158169TerrainGen.ServerChunkCache = {} :: {[typeof("")]: typeof(Chunk.new(0,0,0))}
17101111+local function chunkKeyFromCoords(x: number, y: number, z: number): string
1212+ return `{x},{y},{z}`
1313+end
1414+1515+function TerrainGen:UnloadAllChunks(): number
1616+ local count = 0
1717+ for key in pairs(TerrainGen.ServerChunkCache) do
1818+ TerrainGen.ServerChunkCache[key] = nil
1919+ count += 1
2020+ end
2121+ return count
2222+end
2323+2424+local function worldToChunkCoord(v: number): number
2525+ return math.floor((v + 16) / 32)
2626+end
2727+2828+function TerrainGen:PreloadNearPlayers(radius: number, yRadius: number?): number
2929+ local Players = game:GetService("Players")
3030+ local r = radius or 5
3131+ local ry = yRadius or 1
3232+ local loaded = 0
3333+ for _, player in ipairs(Players:GetPlayers()) do
3434+ local character = player.Character
3535+ local root = character and character:FindFirstChild("HumanoidRootPart")
3636+ if root then
3737+ local pos = root.Position
3838+ local cx = worldToChunkCoord(pos.X)
3939+ local cy = worldToChunkCoord(pos.Y)
4040+ local cz = worldToChunkCoord(pos.Z)
4141+ for y = -ry, ry do
4242+ for x = -r, r do
4343+ for z = -r, r do
4444+ TerrainGen:GetChunk(cx + x, cy + y, cz + z)
4545+ loaded += 1
4646+ end
4747+ end
4848+ end
4949+ end
5050+ end
5151+ return loaded
5252+end
5353+1854-- Load a chunk from the DataStore or generate it if not found
1955function TerrainGen:GetChunk(x, y, z)
2020- local key = `{x},{y},{z}`
5656+ local key = chunkKeyFromCoords(x, y, z)
2157 if TerrainGen.ServerChunkCache[key] then
2258 return TerrainGen.ServerChunkCache[key]
2359 end
2424-6060+2561 -- Generate a new chunk if it doesn't exist
2662 local chunk = Chunk.new(x, y, z)
2763 if y == 1 then
···6610267103 return chunk
68104end
6969-7010571106TerrainGen.CM = ChunkManager
72107
···11+return {
22+ Name = "chunkcull",
33+ Aliases = {"cullchunks", "resetchunks"},
44+ Description = "Unload all server chunk cache instantly, then preload only chunks near players (and force clients to unload/resync).",
55+ Group = "Admin",
66+ Args = {
77+ {
88+ Type = "integer",
99+ Name = "radius",
1010+ Description = "Horizontal chunk radius around each player to preload",
1111+ Optional = true,
1212+ Default = 5,
1313+ },
1414+ {
1515+ Type = "integer",
1616+ Name = "yRadius",
1717+ Description = "Vertical chunk radius around each player to preload",
1818+ Optional = true,
1919+ Default = 1,
2020+ },
2121+ },
2222+ Run = function(context, radius, yRadius)
2323+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
2424+ local Players = game:GetService("Players")
2525+2626+ local terrainGen = require(
2727+ game:GetService("ServerScriptService")
2828+ :WaitForChild("Actor")
2929+ :WaitForChild("ServerChunkManager")
3030+ :WaitForChild("TerrainGen")
3131+ )
3232+3333+ local tickRemote = ReplicatedStorage:WaitForChild("Tick")
3434+3535+ local r = radius or 5
3636+ local ry = yRadius or 1
3737+3838+ local unloaded = 0
3939+ pcall(function()
4040+ unloaded = terrainGen:UnloadAllChunks()
4141+ end)
4242+4343+ -- Tell all clients to immediately drop their local chunk instances
4444+ pcall(function()
4545+ tickRemote:FireAllClients("U_ALL", 0, 0, 0, 0, 0, 0, 0)
4646+ end)
4747+4848+ -- Preload server chunks around players (reduces initial lag spikes after cull)
4949+ local preloaded = 0
5050+ pcall(function()
5151+ preloaded = terrainGen:PreloadNearPlayers(r, ry)
5252+ end)
5353+5454+ -- Force clients to resync around themselves
5555+ local resyncCount = 0
5656+ for _, player in ipairs(Players:GetPlayers()) do
5757+ local character = player.Character
5858+ local root = character and character:FindFirstChild("HumanoidRootPart")
5959+ if root then
6060+ local pos = root.Position
6161+ local cx = math.floor((pos.X + 16) / 32)
6262+ local cy = math.floor((pos.Y + 16) / 32)
6363+ local cz = math.floor((pos.Z + 16) / 32)
6464+ for y = -ry, ry do
6565+ for x = -r, r do
6666+ for z = -r, r do
6767+ tickRemote:FireClient(player, "C_R", cx + x, cy + y, cz + z, 0, 0, 0, 0)
6868+ resyncCount += 1
6969+ end
7070+ end
7171+ end
7272+ end
7373+ end
7474+7575+ return (
7676+ "chunkcull done | unloaded=%d | preloaded=%d | resyncPackets=%d | radius=%d yRadius=%d"
7777+ ):format(unloaded, preloaded, resyncCount, r, ry)
7878+ end,
7979+}
+35-5
src/StarterGui/Hotbar/LocalScript.client.lua
···1515local PM = require(ReplicatedStorage.Shared.PlacementManager)
1616local BlockManager = require(ReplicatedStorage.Shared.ChunkManager.BlockManager)
1717local PlacementState = require(ReplicatedStorage.Shared.PlacementState)
1818+local Util = require(ReplicatedStorage.Shared.Util)
18191920local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")
2021···5657 for _, block in ipairs(blocksFolder:GetChildren()) do
5758 local id = block:GetAttribute("n")
5859 if id ~= nil then
5959- local idStr = tostring(id)
6060- table.insert(ids, idStr)
6161- names[idStr] = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name
6060+ local n = tonumber(id)
6161+ if n and n > 0 then
6262+ local idStr = tostring(n)
6363+ table.insert(ids, idStr)
6464+ names[idStr] = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name
6565+ end
6266 end
6367 end
6468 table.sort(ids, function(a, b)
···133137 local slots, names = buildHotbarIds()
134138 self.state.slots = slots
135139 self.state.names = names
140140+ local initialId = slots and slots[1] or ""
141141+ if initialId and initialId ~= "" then
142142+ local initialName = names and (names[initialId] or initialId) or initialId
143143+ PlacementState:SetSelected(initialId, initialName)
144144+ end
136145137146 self._updateSlots = function()
138147 local nextSlots, nextNames = buildHotbarIds()
···154163 if id ~= "" and self.state.names then
155164 name = self.state.names[id] or id
156165 end
166166+ Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", id, "name", name)
157167 PlacementState:SetSelected(id, name)
158168 end
159169160170 self._handleInput = function(input: InputObject, gameProcessedEvent: boolean)
161161- if gameProcessedEvent or isTextInputFocused() then
171171+ if isTextInputFocused() then
162172 return
163173 end
164174165175 local slot = keyToSlot[input.KeyCode]
166176 if slot then
177177+ if gameProcessedEvent then
178178+ return
179179+ end
167180 self._setSelected(slot)
168181 return
169182 end
170183171184 if input.UserInputType == Enum.UserInputType.MouseButton1 then
185185+ Util.StudioLog("[INPUT][CLIENT]", "MouseButton1", "processed", gameProcessedEvent)
186186+ -- Allow click even if gameProcessedEvent (UI can set this), but only if we're actually pointing at a block
187187+ if not PM:GetBlockAtMouse() then
188188+ return
189189+ end
172190 local mouseBlock = PM:GetBlockAtMouse()
173191 if not mouseBlock then
174192 return
···182200 mouseBlock.block.Z
183201 )
184202 elseif input.UserInputType == Enum.UserInputType.MouseButton2 then
185185- local mouseBlock = PM:GetPlacementAtMouse()
203203+ Util.StudioLog("[INPUT][CLIENT]", "MouseButton2", "processed", gameProcessedEvent)
204204+ -- Allow click even if gameProcessedEvent (UI can set this), but only if we're actually pointing at a block
205205+ local mouseBlock = PM:DebugGetPlacementOrWarn()
186206 if not mouseBlock then
187207 return
188208 end
189209 local id = PlacementState:GetSelected()
190210 if not id or id == "" then
211211+ Util.StudioWarn("[PLACE][CLIENT][REJECT]", "no selected id")
191212 return
192213 end
214214+ Util.StudioLog(
215215+ "[PLACE][CLIENT][SEND][CLICK]",
216216+ "chunk",
217217+ mouseBlock.chunk,
218218+ "block",
219219+ mouseBlock.block,
220220+ "id",
221221+ id
222222+ )
193223 PM:PlaceBlock(
194224 mouseBlock.chunk.X,
195225 mouseBlock.chunk.Y,