diff --git a/src/ReplicatedStorage/Shared/ChunkManager/Chunk.lua b/src/ReplicatedStorage/Shared/ChunkManager/Chunk.lua index 091c992..9047bec 100644 --- a/src/ReplicatedStorage/Shared/ChunkManager/Chunk.lua +++ b/src/ReplicatedStorage/Shared/ChunkManager/Chunk.lua @@ -196,6 +196,19 @@ function Chunk:Unload() end) end +function Chunk:UnloadImmediate() + self.loaded = false + pcall(function() + self.unloadChunkHook() + end) + pcall(function() + if self.instance then + self.instance.Parent = nil + self.instance:Destroy() + end + end) +end + -- DO NOT INTERACT WITH CHUNK AFTER CALLING THIS function Chunk:Destroy() self.data = {} diff --git a/src/ReplicatedStorage/Shared/ChunkManager/init.lua b/src/ReplicatedStorage/Shared/ChunkManager/init.lua index 1226ddf..db946aa 100644 --- a/src/ReplicatedStorage/Shared/ChunkManager/init.lua +++ b/src/ReplicatedStorage/Shared/ChunkManager/init.lua @@ -31,6 +31,16 @@ local FORCELOAD_CHUNKS = { local unloadingChunks = {} local pendingChunkRequests = {} +local lastChunkKey: string? = nil +local lastHeavyTick = 0 +local HEAVY_TICK_INTERVAL = 1.5 +local lastUnloadSweep = 0 +local UNLOAD_SWEEP_INTERVAL = 1.5 + +local function worldToChunkCoord(v: number): number + return math.floor((v + 16) / 32) +end + local CHUNK_OFFSETS = {} do for y = -CHUNK_RADIUS, CHUNK_RADIUS do @@ -45,6 +55,22 @@ do end) end +function ChunkManager:UnloadAllNow() + for key, chunk in pairs(Chunk.AllChunks) do + unloadingChunks[key] = true + pcall(function() + if chunk.loaded then + chunk:UnloadImmediate() + end + end) + pcall(function() + chunk:Destroy() + end) + Chunk.AllChunks[key] = nil + unloadingChunks[key] = nil + end +end + local function Swait(l) for _ = 1, l do RunService.Stepped:Wait() @@ -232,26 +258,40 @@ function ChunkManager:Tick() local pos = player.Character:GetPivot().Position local chunkPos = { - x = math.round(pos.X / 32), - y = math.round(pos.Y / 32), - z = math.round(pos.Z / 32) + x = worldToChunkCoord(pos.X), + y = worldToChunkCoord(pos.Y), + z = worldToChunkCoord(pos.Z) } + local ck = `{chunkPos.x},{chunkPos.y},{chunkPos.z}` + local now = tick() + local shouldHeavyTick = (ck ~= lastChunkKey) or (now - lastHeavyTick >= HEAVY_TICK_INTERVAL) + lastChunkKey = ck + if shouldHeavyTick then + lastHeavyTick = now + end - task.defer(function() - local processed = 0 - for _, offset in ipairs(CHUNK_OFFSETS) do - local cx, cy, cz = chunkPos.x + offset[1], chunkPos.y + offset[2], chunkPos.z + offset[3] - local chunk = ChunkManager:GetChunk(cx, cy, cz) - chunk.inhabitedTime = tick() - if not chunk.loaded then - ChunkManager:LoadChunk(cx, cy, cz) - processed += 1 - if processed % LOAD_BATCH == 0 then - Swait(1) + if shouldHeavyTick then + task.defer(function() + local processed = 0 + for _, offset in ipairs(CHUNK_OFFSETS) do + local cx, cy, cz = chunkPos.x + offset[1], chunkPos.y + offset[2], chunkPos.z + offset[3] + local chunk = ChunkManager:GetChunk(cx, cy, cz) + chunk.inhabitedTime = now + if not chunk.loaded then + ChunkManager:LoadChunk(cx, cy, cz) + processed += 1 + if processed % LOAD_BATCH == 0 then + Swait(1) + end end end + end) + else + local current = Chunk.AllChunks[ck] + if current then + current.inhabitedTime = now end - end) + end --[[ task.defer(function() @@ -275,15 +315,18 @@ function ChunkManager:Tick() end) --]] - for key, loadedChunk in pairs(Chunk.AllChunks) do - if tick() - loadedChunk.inhabitedTime > 15 and not unloadingChunks[key] then - unloadingChunks[key] = true - task.defer(function() - loadedChunk:Unload() - loadedChunk:Destroy() - Chunk.AllChunks[key] = nil - unloadingChunks[key] = nil - end) + if now - lastUnloadSweep >= UNLOAD_SWEEP_INTERVAL then + lastUnloadSweep = now + for key, loadedChunk in pairs(Chunk.AllChunks) do + if now - loadedChunk.inhabitedTime > 15 and not unloadingChunks[key] then + unloadingChunks[key] = true + task.defer(function() + loadedChunk:Unload() + loadedChunk:Destroy() + Chunk.AllChunks[key] = nil + unloadingChunks[key] = nil + end) + end end end end @@ -295,9 +338,9 @@ function ChunkManager:ResyncAroundPlayer(radius: number) end local pos = player.Character:GetPivot().Position local chunkPos = { - x = math.round(pos.X / 32), - y = math.round(pos.Y / 32), - z = math.round(pos.Z / 32) + x = worldToChunkCoord(pos.X), + y = worldToChunkCoord(pos.Y), + z = worldToChunkCoord(pos.Z) } for y = -radius, radius do for x = -radius, radius do @@ -327,6 +370,12 @@ function ChunkManager:Init() ChunkFolder.Parent = game:GetService("Workspace") ChunkManager:ForceTick() + tickremote.OnClientEvent:Connect(function(m) + if m == "U_ALL" then + ChunkManager:UnloadAllNow() + end + end) + task.defer(function() while true do wait(2) diff --git a/src/ReplicatedStorage/Shared/PlacementManager.lua b/src/ReplicatedStorage/Shared/PlacementManager.lua index 3f5eda7..1d79122 100644 --- a/src/ReplicatedStorage/Shared/PlacementManager.lua +++ b/src/ReplicatedStorage/Shared/PlacementManager.lua @@ -6,6 +6,19 @@ 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() @@ -13,12 +26,15 @@ raycastParams.FilterDescendantsInstances = {PlacementManager.ChunkFolder} raycastParams.FilterType = Enum.RaycastFilterType.Include raycastParams.IgnoreWater = true -if _G.SB then return nil end -_G.SB = true +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 -- Trash method TODO: Fix this local function findChunkFolderFromDescendant(inst: Instance): Instance? @@ -27,15 +43,83 @@ local function findChunkFolderFromDescendant(inst: Instance): Instance? 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 + +local function clearSelection(reason: string?) + PlacementManager.SelectionBox.Adornee = nil + PlacementManager.SelectionBox.Parent = nil + lastNormalId = 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 Mouse: Mouse = nil local lastNormalId: Enum.NormalId? = nil local BREAK_ROLLBACK_TIMEOUT = 0.6 local pendingBreaks = {} +local lastRaycastFailure: string? = nil +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}` @@ -150,38 +234,127 @@ local function isWithinReach(cx: number, cy: number, cz: number, x: number, y: n 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 objLookingAt = Mouse.Target - local dir = Mouse.TargetSurface or Enum.NormalId.Top - if not objLookingAt then - PlacementManager.SelectionBox.Adornee = nil - script.RaycastResult.Value = nil - lastNormalId = nil - return - end - - --if not objLookingAt:IsDescendantOf(ChunkManager.ChunkFolder) then return end - local chunkFolder = findChunkFolderFromDescendant(objLookingAt) + local chunkFolder = ensureChunkFolder() if not chunkFolder then - PlacementManager.SelectionBox.Adornee = nil + clearSelection("chunk folder missing") script.RaycastResult.Value = nil - lastNormalId = nil return end - if chunkFolder:GetAttribute("ns") == true then - PlacementManager.SelectionBox.Adornee = nil - script.RaycastResult.Value = nil - lastNormalId = nil + + raycastParams.FilterDescendantsInstances = {chunkFolder} + local cam = workspace.CurrentCamera + if not cam then + lastRaycastFailure = "no camera" return end - PlacementManager.SelectionBox.Adornee = objLookingAt + 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 + + -- 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 + setSelection(blockInstance, PlacementManager.ChunkFolder) script.RaycastResult.Value = objLookingAt - lastNormalId = dir - return objLookingAt, dir + 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() @@ -195,18 +368,70 @@ 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 @@ -218,6 +443,7 @@ function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockId: string) }) end + debugPlacementLog("[PLACE][CLIENT][SEND]", cx, cy, cz, x, y, z, blockId) placeRemote:FireServer(cx, cy, cz, x, y, z, blockId) end @@ -227,12 +453,15 @@ function PlacementManager:BreakBlock(cx, cy, cz, x, y, z) 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 @@ -241,6 +470,7 @@ function PlacementManager:BreakBlock(cx, cy, cz, x, y, z) 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 {} @@ -252,6 +482,7 @@ function PlacementManager:BreakBlock(cx, cy, cz, x, y, z) 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 @@ -283,28 +514,54 @@ local function applyBreakBlockLocal(cx, cy, cz, 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 PlacementManager.SelectionBox.Adornee = nil script.RaycastResult.Value = nil lastNormalId = nil + debugPlacementLog("[PLACE][CLIENT][TARGET]", "no selectedPart after raycast", lastRaycastFailure) return nil end - if not selectedPart.Parent then - PlacementManager.SelectionBox.Adornee = nil - script.RaycastResult.Value = nil - lastNormalId = nil + 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 - if not selectedPart.Parent then - PlacementManager.SelectionBox.Adornee = nil - script.RaycastResult.Value = nil - lastNormalId = nil + + 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 - local chunkCoords = Util.BlockPosStringToCoords(selectedPart.Parent.Name) - local blockCoords = Util.BlockPosStringToCoords(selectedPart.Name) + debugPlacementLog( + "[PLACE][CLIENT][TARGET]", + "chunk", + chunkName, + "block", + blockName, + "normal", + (lastNormalId and lastNormalId.Name) or "nil" + ) return { chunk = chunkCoords, @@ -334,12 +591,33 @@ function PlacementManager:GetPlacementAtMouse(): nil | {chunk:Vector3, block: Ve 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() diff --git a/src/ReplicatedStorage/Shared/PlacementState.lua b/src/ReplicatedStorage/Shared/PlacementState.lua index c4e4fea..4fdca42 100644 --- a/src/ReplicatedStorage/Shared/PlacementState.lua +++ b/src/ReplicatedStorage/Shared/PlacementState.lua @@ -23,6 +23,8 @@ function PlacementState:SetSelected(id: string?, name: string?) selectedId = id or "" selectedName = name or selectedId valueObject.Value = selectedName or "" + local Util = require(ReplicatedStorage:WaitForChild("Shared"):WaitForChild("Util")) + Util.StudioLog("[PLACE][CLIENT][SELECT]", "id", selectedId, "name", selectedName) changed:Fire(selectedId, selectedName) end diff --git a/src/ReplicatedStorage/Shared/Util.lua b/src/ReplicatedStorage/Shared/Util.lua index 8b63fae..5ca4f8b 100644 --- a/src/ReplicatedStorage/Shared/Util.lua +++ b/src/ReplicatedStorage/Shared/Util.lua @@ -1,5 +1,23 @@ +local RunService = game:GetService("RunService") +local IS_STUDIO = RunService:IsStudio() + local module = {} +-- Prints only when running in Studio (avoids noisy live logs) +function module.StudioLog(...: any) + if not IS_STUDIO then + return + end + print(...) +end + +function module.StudioWarn(...: any) + if not IS_STUDIO then + return + end + warn(...) +end + function module.isNaN(n: number): boolean -- NaN is never equal to itself return n ~= n diff --git a/src/ServerScriptService/Actor/ServerChunkManager/TerrainGen/init.lua b/src/ServerScriptService/Actor/ServerChunkManager/TerrainGen/init.lua index 9e717f5..acc0534 100644 --- a/src/ServerScriptService/Actor/ServerChunkManager/TerrainGen/init.lua +++ b/src/ServerScriptService/Actor/ServerChunkManager/TerrainGen/init.lua @@ -3,25 +3,61 @@ local TerrainGen = {} -local deflate = require("./TerrainGen/Deflate") - -local DSS = game:GetService("DataStoreService") -local WORLDNAME = "DEFAULT" -local WORLDID = "b73bb5a6-297d-4352-b637-daec7e8c8f3e" -local Store = DSS:GetDataStore("BlockscraftWorldV1", WORLDID) - local ChunkManager = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared").ChunkManager) local Chunk = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared").ChunkManager.Chunk) TerrainGen.ServerChunkCache = {} :: {[typeof("")]: typeof(Chunk.new(0,0,0))} +local function chunkKeyFromCoords(x: number, y: number, z: number): string + return `{x},{y},{z}` +end + +function TerrainGen:UnloadAllChunks(): number + local count = 0 + for key in pairs(TerrainGen.ServerChunkCache) do + TerrainGen.ServerChunkCache[key] = nil + count += 1 + end + return count +end + +local function worldToChunkCoord(v: number): number + return math.floor((v + 16) / 32) +end + +function TerrainGen:PreloadNearPlayers(radius: number, yRadius: number?): number + local Players = game:GetService("Players") + local r = radius or 5 + local ry = yRadius or 1 + local loaded = 0 + for _, player in ipairs(Players:GetPlayers()) do + local character = player.Character + local root = character and character:FindFirstChild("HumanoidRootPart") + if root then + local pos = root.Position + local cx = worldToChunkCoord(pos.X) + local cy = worldToChunkCoord(pos.Y) + local cz = worldToChunkCoord(pos.Z) + for y = -ry, ry do + for x = -r, r do + for z = -r, r do + TerrainGen:GetChunk(cx + x, cy + y, cz + z) + loaded += 1 + end + end + end + end + end + return loaded +end + -- Load a chunk from the DataStore or generate it if not found function TerrainGen:GetChunk(x, y, z) - local key = `{x},{y},{z}` + local key = chunkKeyFromCoords(x, y, z) if TerrainGen.ServerChunkCache[key] then return TerrainGen.ServerChunkCache[key] end - + -- Generate a new chunk if it doesn't exist local chunk = Chunk.new(x, y, z) if y == 1 then @@ -67,7 +103,6 @@ function TerrainGen:GetFakeChunk(x, y, z) return chunk end - TerrainGen.CM = ChunkManager return TerrainGen diff --git a/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua b/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua index 06fefd6..cb403b4 100644 --- a/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua +++ b/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua @@ -139,12 +139,21 @@ local function isBlockInsidePlayer(blockPos: Vector3): boolean end 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 placeRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockId) local function reject(reason: string) - if DEBUG_PLACEMENT then - warn("[PLACE][REJECT]", player.Name, reason, "chunk", cx, cy, cz, "block", x, y, z, "id", blockId) - end + debugPlacementWarn("[PLACE][REJECT]", player.Name, reason, "chunk", cx, cy, cz, "block", x, y, z, "id", blockId) return end @@ -178,9 +187,7 @@ placeRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockId) if existing and existing.id and existing.id ~= 0 then if existing.id == resolvedId then -- same block already there; treat as success without changes - if DEBUG_PLACEMENT then - print("[PLACE][OK][NOOP]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId) - end + debugPlacementLog("[PLACE][OK][NOOP]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId) return end -- allow replacement when different id: remove then place @@ -192,9 +199,7 @@ placeRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockId) } chunk:CreateBlock(x, y, z, data) propogate("B_C", cx, cy, cz, x, y, z, data) - if DEBUG_PLACEMENT then - print("[PLACE][OK]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId) - end + debugPlacementLog("[PLACE][OK]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId) end) breakRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z) @@ -219,10 +224,12 @@ breakRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z) task.synchronize() tickRemote:FireClient(player, "C_R", cx, cy, cz, 0, 0, 0, 0) task.desynchronize() + debugPlacementLog("[BREAK][NOOP]", player.Name, "chunk", cx, cy, cz, "block", x, y, z) return end chunk:RemoveBlock(x, y, z) propogate("B_D", cx, cy, cz, x, y, z, 0) + debugPlacementLog("[BREAK][OK]", player.Name, "chunk", cx, cy, cz, "block", x, y, z) end) task.desynchronize() diff --git a/src/ServerScriptService/CmdrCommands/ChunkCullWorld.lua b/src/ServerScriptService/CmdrCommands/ChunkCullWorld.lua new file mode 100644 index 0000000..18a45d4 --- /dev/null +++ b/src/ServerScriptService/CmdrCommands/ChunkCullWorld.lua @@ -0,0 +1,79 @@ +return { + Name = "chunkcull", + Aliases = {"cullchunks", "resetchunks"}, + Description = "Unload all server chunk cache instantly, then preload only chunks near players (and force clients to unload/resync).", + Group = "Admin", + Args = { + { + Type = "integer", + Name = "radius", + Description = "Horizontal chunk radius around each player to preload", + Optional = true, + Default = 5, + }, + { + Type = "integer", + Name = "yRadius", + Description = "Vertical chunk radius around each player to preload", + Optional = true, + Default = 1, + }, + }, + Run = function(context, radius, yRadius) + local ReplicatedStorage = game:GetService("ReplicatedStorage") + local Players = game:GetService("Players") + + local terrainGen = require( + game:GetService("ServerScriptService") + :WaitForChild("Actor") + :WaitForChild("ServerChunkManager") + :WaitForChild("TerrainGen") + ) + + local tickRemote = ReplicatedStorage:WaitForChild("Tick") + + local r = radius or 5 + local ry = yRadius or 1 + + local unloaded = 0 + pcall(function() + unloaded = terrainGen:UnloadAllChunks() + end) + + -- Tell all clients to immediately drop their local chunk instances + pcall(function() + tickRemote:FireAllClients("U_ALL", 0, 0, 0, 0, 0, 0, 0) + end) + + -- Preload server chunks around players (reduces initial lag spikes after cull) + local preloaded = 0 + pcall(function() + preloaded = terrainGen:PreloadNearPlayers(r, ry) + end) + + -- Force clients to resync around themselves + local resyncCount = 0 + for _, player in ipairs(Players:GetPlayers()) do + local character = player.Character + local root = character and character:FindFirstChild("HumanoidRootPart") + if root then + local pos = root.Position + local cx = math.floor((pos.X + 16) / 32) + local cy = math.floor((pos.Y + 16) / 32) + local cz = math.floor((pos.Z + 16) / 32) + for y = -ry, ry do + for x = -r, r do + for z = -r, r do + tickRemote:FireClient(player, "C_R", cx + x, cy + y, cz + z, 0, 0, 0, 0) + resyncCount += 1 + end + end + end + end + end + + return ( + "chunkcull done | unloaded=%d | preloaded=%d | resyncPackets=%d | radius=%d yRadius=%d" + ):format(unloaded, preloaded, resyncCount, r, ry) + end, +} diff --git a/src/StarterGui/Hotbar/LocalScript.client.lua b/src/StarterGui/Hotbar/LocalScript.client.lua index dfa1867..9ca52ed 100644 --- a/src/StarterGui/Hotbar/LocalScript.client.lua +++ b/src/StarterGui/Hotbar/LocalScript.client.lua @@ -15,6 +15,7 @@ local Roact = require(ReplicatedStorage.Packages.roact) local PM = require(ReplicatedStorage.Shared.PlacementManager) local BlockManager = require(ReplicatedStorage.Shared.ChunkManager.BlockManager) local PlacementState = require(ReplicatedStorage.Shared.PlacementState) +local Util = require(ReplicatedStorage.Shared.Util) local blocksFolder = ReplicatedStorage:WaitForChild("Blocks") @@ -56,9 +57,12 @@ local function buildHotbarIds(): {string} for _, block in ipairs(blocksFolder:GetChildren()) do local id = block:GetAttribute("n") if id ~= nil then - local idStr = tostring(id) - table.insert(ids, idStr) - names[idStr] = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name + local n = tonumber(id) + if n and n > 0 then + local idStr = tostring(n) + table.insert(ids, idStr) + names[idStr] = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name + end end end table.sort(ids, function(a, b) @@ -133,6 +137,11 @@ function Hotbar:init() local slots, names = buildHotbarIds() self.state.slots = slots self.state.names = names + local initialId = slots and slots[1] or "" + if initialId and initialId ~= "" then + local initialName = names and (names[initialId] or initialId) or initialId + PlacementState:SetSelected(initialId, initialName) + end self._updateSlots = function() local nextSlots, nextNames = buildHotbarIds() @@ -154,21 +163,30 @@ function Hotbar:init() if id ~= "" and self.state.names then name = self.state.names[id] or id end + Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", id, "name", name) PlacementState:SetSelected(id, name) end self._handleInput = function(input: InputObject, gameProcessedEvent: boolean) - if gameProcessedEvent or isTextInputFocused() then + if isTextInputFocused() then return end local slot = keyToSlot[input.KeyCode] if slot then + if gameProcessedEvent then + return + end self._setSelected(slot) return end if input.UserInputType == Enum.UserInputType.MouseButton1 then + Util.StudioLog("[INPUT][CLIENT]", "MouseButton1", "processed", gameProcessedEvent) + -- Allow click even if gameProcessedEvent (UI can set this), but only if we're actually pointing at a block + if not PM:GetBlockAtMouse() then + return + end local mouseBlock = PM:GetBlockAtMouse() if not mouseBlock then return @@ -182,14 +200,26 @@ function Hotbar:init() mouseBlock.block.Z ) elseif input.UserInputType == Enum.UserInputType.MouseButton2 then - local mouseBlock = PM:GetPlacementAtMouse() + Util.StudioLog("[INPUT][CLIENT]", "MouseButton2", "processed", gameProcessedEvent) + -- Allow click even if gameProcessedEvent (UI can set this), but only if we're actually pointing at a block + local mouseBlock = PM:DebugGetPlacementOrWarn() if not mouseBlock then return end local id = PlacementState:GetSelected() if not id or id == "" then + Util.StudioWarn("[PLACE][CLIENT][REJECT]", "no selected id") return end + Util.StudioLog( + "[PLACE][CLIENT][SEND][CLICK]", + "chunk", + mouseBlock.chunk, + "block", + mouseBlock.block, + "id", + id + ) PM:PlaceBlock( mouseBlock.chunk.X, mouseBlock.chunk.Y,