core: fix building

This commit is contained in:
2026-01-08 19:30:49 +02:00
committed by Tangled
parent 165913ca51
commit b34830a493
9 changed files with 594 additions and 83 deletions

View File

@@ -196,6 +196,19 @@ function Chunk:Unload()
end) end)
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 -- DO NOT INTERACT WITH CHUNK AFTER CALLING THIS
function Chunk:Destroy() function Chunk:Destroy()
self.data = {} self.data = {}

View File

@@ -31,6 +31,16 @@ local FORCELOAD_CHUNKS = {
local unloadingChunks = {} local unloadingChunks = {}
local pendingChunkRequests = {} 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 = {} local CHUNK_OFFSETS = {}
do do
for y = -CHUNK_RADIUS, CHUNK_RADIUS do for y = -CHUNK_RADIUS, CHUNK_RADIUS do
@@ -45,6 +55,22 @@ do
end) end)
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) local function Swait(l)
for _ = 1, l do for _ = 1, l do
RunService.Stepped:Wait() RunService.Stepped:Wait()
@@ -232,26 +258,40 @@ function ChunkManager:Tick()
local pos = player.Character:GetPivot().Position local pos = player.Character:GetPivot().Position
local chunkPos = { local chunkPos = {
x = math.round(pos.X / 32), x = worldToChunkCoord(pos.X),
y = math.round(pos.Y / 32), y = worldToChunkCoord(pos.Y),
z = math.round(pos.Z / 32) 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() if shouldHeavyTick then
local processed = 0 task.defer(function()
for _, offset in ipairs(CHUNK_OFFSETS) do local processed = 0
local cx, cy, cz = chunkPos.x + offset[1], chunkPos.y + offset[2], chunkPos.z + offset[3] for _, offset in ipairs(CHUNK_OFFSETS) do
local chunk = ChunkManager:GetChunk(cx, cy, cz) local cx, cy, cz = chunkPos.x + offset[1], chunkPos.y + offset[2], chunkPos.z + offset[3]
chunk.inhabitedTime = tick() local chunk = ChunkManager:GetChunk(cx, cy, cz)
if not chunk.loaded then chunk.inhabitedTime = now
ChunkManager:LoadChunk(cx, cy, cz) if not chunk.loaded then
processed += 1 ChunkManager:LoadChunk(cx, cy, cz)
if processed % LOAD_BATCH == 0 then processed += 1
Swait(1) if processed % LOAD_BATCH == 0 then
Swait(1)
end
end end
end end
end)
else
local current = Chunk.AllChunks[ck]
if current then
current.inhabitedTime = now
end end
end) end
--[[ --[[
task.defer(function() task.defer(function()
@@ -275,15 +315,18 @@ function ChunkManager:Tick()
end) end)
--]] --]]
for key, loadedChunk in pairs(Chunk.AllChunks) do if now - lastUnloadSweep >= UNLOAD_SWEEP_INTERVAL then
if tick() - loadedChunk.inhabitedTime > 15 and not unloadingChunks[key] then lastUnloadSweep = now
unloadingChunks[key] = true for key, loadedChunk in pairs(Chunk.AllChunks) do
task.defer(function() if now - loadedChunk.inhabitedTime > 15 and not unloadingChunks[key] then
loadedChunk:Unload() unloadingChunks[key] = true
loadedChunk:Destroy() task.defer(function()
Chunk.AllChunks[key] = nil loadedChunk:Unload()
unloadingChunks[key] = nil loadedChunk:Destroy()
end) Chunk.AllChunks[key] = nil
unloadingChunks[key] = nil
end)
end
end end
end end
end end
@@ -295,9 +338,9 @@ function ChunkManager:ResyncAroundPlayer(radius: number)
end end
local pos = player.Character:GetPivot().Position local pos = player.Character:GetPivot().Position
local chunkPos = { local chunkPos = {
x = math.round(pos.X / 32), x = worldToChunkCoord(pos.X),
y = math.round(pos.Y / 32), y = worldToChunkCoord(pos.Y),
z = math.round(pos.Z / 32) z = worldToChunkCoord(pos.Z)
} }
for y = -radius, radius do for y = -radius, radius do
for x = -radius, radius do for x = -radius, radius do
@@ -327,6 +370,12 @@ function ChunkManager:Init()
ChunkFolder.Parent = game:GetService("Workspace") ChunkFolder.Parent = game:GetService("Workspace")
ChunkManager:ForceTick() ChunkManager:ForceTick()
tickremote.OnClientEvent:Connect(function(m)
if m == "U_ALL" then
ChunkManager:UnloadAllNow()
end
end)
task.defer(function() task.defer(function()
while true do while true do
wait(2) wait(2)

View File

@@ -6,6 +6,19 @@ local PlacementManager = {}
local ChunkManager = require("./ChunkManager") local ChunkManager = require("./ChunkManager")
local Util = require("./Util") 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 PlacementManager.ChunkFolder = ChunkManager.ChunkFolder
local raycastParams = RaycastParams.new() local raycastParams = RaycastParams.new()
@@ -13,12 +26,15 @@ raycastParams.FilterDescendantsInstances = {PlacementManager.ChunkFolder}
raycastParams.FilterType = Enum.RaycastFilterType.Include raycastParams.FilterType = Enum.RaycastFilterType.Include
raycastParams.IgnoreWater = true raycastParams.IgnoreWater = true
if _G.SB then return nil end if _G.__BLOCKSCRAFT_PLACEMENT_MANAGER then
_G.SB = true return _G.__BLOCKSCRAFT_PLACEMENT_MANAGER
end
_G.__BLOCKSCRAFT_PLACEMENT_MANAGER = PlacementManager
PlacementManager.SelectionBox = script.SelectionBox:Clone() PlacementManager.SelectionBox = script.SelectionBox:Clone()
PlacementManager.SelectionBox.Name = "$SelectionBox"..(game:GetService("RunService"):IsServer() and "_SERVER" or "") PlacementManager.SelectionBox.Name = "$SelectionBox"..(game:GetService("RunService"):IsServer() and "_SERVER" or "")
PlacementManager.SelectionBox.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain") PlacementManager.SelectionBox.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain")
PlacementManager.SelectionBox.Adornee = nil
-- Trash method TODO: Fix this -- Trash method TODO: Fix this
local function findChunkFolderFromDescendant(inst: Instance): Instance? local function findChunkFolderFromDescendant(inst: Instance): Instance?
@@ -27,15 +43,83 @@ local function findChunkFolderFromDescendant(inst: Instance): Instance?
if current.Parent == PlacementManager.ChunkFolder then if current.Parent == PlacementManager.ChunkFolder then
return current return current
end 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 current = current.Parent
end end
return nil return nil
end 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 Mouse: Mouse = nil
local lastNormalId: Enum.NormalId? = nil local lastNormalId: Enum.NormalId? = nil
local BREAK_ROLLBACK_TIMEOUT = 0.6 local BREAK_ROLLBACK_TIMEOUT = 0.6
local pendingBreaks = {} 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 local function makeChunkKey(cx: number, cy: number, cz: number): string
return `{cx},{cy},{cz}` return `{cx},{cy},{cz}`
@@ -150,38 +234,127 @@ local function isWithinReach(cx: number, cy: number, cz: number, x: number, y: n
return true return true
end 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 -- Gets the block and normalid of the block (and surface) the player is looking at
function PlacementManager:Raycast() function PlacementManager:Raycast()
if not Mouse then if not Mouse then
Mouse = game:GetService("Players").LocalPlayer:GetMouse() Mouse = game:GetService("Players").LocalPlayer:GetMouse()
end end
local objLookingAt = Mouse.Target local chunkFolder = ensureChunkFolder()
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)
if not chunkFolder then if not chunkFolder then
PlacementManager.SelectionBox.Adornee = nil clearSelection("chunk folder missing")
script.RaycastResult.Value = nil script.RaycastResult.Value = nil
lastNormalId = nil
return return
end end
if chunkFolder:GetAttribute("ns") == true then
PlacementManager.SelectionBox.Adornee = nil raycastParams.FilterDescendantsInstances = {chunkFolder}
script.RaycastResult.Value = nil local cam = workspace.CurrentCamera
lastNormalId = nil if not cam then
lastRaycastFailure = "no camera"
return return
end 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 script.RaycastResult.Value = objLookingAt
lastNormalId = dir lastNormalId = vectorToNormalId(result.Normal)
return objLookingAt, dir debugPlacementLog(
"[PLACE][CLIENT][RAYCAST][HIT]",
blockInstance:GetFullName(),
"chunkFolder",
hitChunkFolder:GetFullName(),
"blockName",
blockInstance.Name,
"normal",
lastNormalId.Name
)
return objLookingAt, lastNormalId
end end
function PlacementManager:RaycastGetResult() function PlacementManager:RaycastGetResult()
@@ -195,18 +368,70 @@ local tickRemote = game:GetService("ReplicatedStorage").Tick
-- FIRES REMOTE -- FIRES REMOTE
function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockId: string) 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 -- ensure chunk is present/rendered client-side
local chunk = ChunkManager:GetChunk(cx, cy, cz) local chunk = ChunkManager:GetChunk(cx, cy, cz)
if chunk and not chunk.loaded then if chunk and not chunk.loaded then
ChunkManager:LoadChunk(cx, cy, cz) ChunkManager:LoadChunk(cx, cy, cz)
end 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 the client already thinks this block is the same id, skip sending
if chunk then if chunk then
local existing = chunk:GetBlockAt(x, y, z) local existing = chunk:GetBlockAt(x, y, z)
local existingId = existing and existing.id local existingId = existing and existing.id
if existingId and tostring(existingId) == tostring(blockId) then 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 return
else
debugPlacementLog(
"[PLACE][CLIENT][EXISTING]",
"chunk",
cx,
cy,
cz,
"block",
x,
y,
z,
"existingId",
existingId
)
end end
end end
@@ -218,6 +443,7 @@ function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockId: string)
}) })
end end
debugPlacementLog("[PLACE][CLIENT][SEND]", cx, cy, cz, x, y, z, blockId)
placeRemote:FireServer(cx, cy, cz, x, y, z, blockId) placeRemote:FireServer(cx, cy, cz, x, y, z, blockId)
end end
@@ -227,12 +453,15 @@ function PlacementManager:BreakBlock(cx, cy, cz, x, y, z)
return return
end end
if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then 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 return
end end
if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then 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 return
end end
if not isWithinReach(cx, cy, cz, x, y, z) then if not isWithinReach(cx, cy, cz, x, y, z) then
debugPlacementWarn("[BREAK][CLIENT][REJECT]", "reach", cx, cy, cz, x, y, z)
return return
end end
@@ -241,6 +470,7 @@ function PlacementManager:BreakBlock(cx, cy, cz, x, y, z)
local chunkKey = makeChunkKey(cx, cy, cz) local chunkKey = makeChunkKey(cx, cy, cz)
local blockKey = makeBlockKey(x, y, z) local blockKey = makeBlockKey(x, y, z)
if getPendingBreak(chunkKey, blockKey) then if getPendingBreak(chunkKey, blockKey) then
debugPlacementLog("[BREAK][CLIENT][SKIP]", "pending rollback", cx, cy, cz, x, y, z)
return return
end end
pendingBreaks[chunkKey] = pendingBreaks[chunkKey] or {} pendingBreaks[chunkKey] = pendingBreaks[chunkKey] or {}
@@ -252,6 +482,7 @@ function PlacementManager:BreakBlock(cx, cy, cz, x, y, z)
chunk:RemoveBlock(x, y, z) chunk:RemoveBlock(x, y, z)
end end
scheduleBreakRollback(cx, cy, cz, x, y, z) 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) breakRemote:FireServer(cx, cy, cz, x, y, z)
end end
@@ -283,28 +514,54 @@ local function applyBreakBlockLocal(cx, cy, cz, x, y, z)
end end
function PlacementManager:GetBlockAtMouse(): nil | {chunk:Vector3, block: Vector3} function PlacementManager:GetBlockAtMouse(): nil | {chunk:Vector3, block: Vector3}
pcall(function()
PlacementManager:Raycast()
end)
local selectedPart = PlacementManager:RaycastGetResult() local selectedPart = PlacementManager:RaycastGetResult()
--print(selectedPart and selectedPart:GetFullName() or nil) --print(selectedPart and selectedPart:GetFullName() or nil)
if selectedPart == nil then if selectedPart == nil then
PlacementManager.SelectionBox.Adornee = nil PlacementManager.SelectionBox.Adornee = nil
script.RaycastResult.Value = nil script.RaycastResult.Value = nil
lastNormalId = nil lastNormalId = nil
debugPlacementLog("[PLACE][CLIENT][TARGET]", "no selectedPart after raycast", lastRaycastFailure)
return nil return nil
end end
if not selectedPart.Parent then local chunkName, blockName = findChunkAndBlock(selectedPart)
PlacementManager.SelectionBox.Adornee = nil if not chunkName or not blockName then
script.RaycastResult.Value = nil debugPlacementWarn(
lastNormalId = nil "[PLACE][CLIENT][TARGET]",
"failed to find chunk/block from selection",
selectedPart:GetFullName()
)
return nil return nil
end end
if not selectedPart.Parent then
PlacementManager.SelectionBox.Adornee = nil local okChunk, chunkCoords = pcall(function()
script.RaycastResult.Value = nil return Util.BlockPosStringToCoords(chunkName :: string)
lastNormalId = nil 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 return nil
end end
local chunkCoords = Util.BlockPosStringToCoords(selectedPart.Parent.Name) debugPlacementLog(
local blockCoords = Util.BlockPosStringToCoords(selectedPart.Name) "[PLACE][CLIENT][TARGET]",
"chunk",
chunkName,
"block",
blockName,
"normal",
(lastNormalId and lastNormalId.Name) or "nil"
)
return { return {
chunk = chunkCoords, chunk = chunkCoords,
@@ -334,12 +591,33 @@ function PlacementManager:GetPlacementAtMouse(): nil | {chunk:Vector3, block: Ve
end end
local offset = normalIdToOffset(hit.normal) local offset = normalIdToOffset(hit.normal)
local placeChunk, placeBlock = offsetChunkBlock(hit.chunk, hit.block, offset) 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 { return {
chunk = placeChunk, chunk = placeChunk,
block = placeBlock block = placeBlock
} }
end 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() function PlacementManager:Init()
game:GetService("RunService").RenderStepped:Connect(function() game:GetService("RunService").RenderStepped:Connect(function()
local a,b = pcall(function() local a,b = pcall(function()

View File

@@ -23,6 +23,8 @@ function PlacementState:SetSelected(id: string?, name: string?)
selectedId = id or "" selectedId = id or ""
selectedName = name or selectedId selectedName = name or selectedId
valueObject.Value = selectedName or "" 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) changed:Fire(selectedId, selectedName)
end end

View File

@@ -1,5 +1,23 @@
local RunService = game:GetService("RunService")
local IS_STUDIO = RunService:IsStudio()
local module = {} 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 function module.isNaN(n: number): boolean
-- NaN is never equal to itself -- NaN is never equal to itself
return n ~= n return n ~= n

View File

@@ -3,25 +3,61 @@
local TerrainGen = {} 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 ChunkManager = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared").ChunkManager)
local Chunk = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared").ChunkManager.Chunk) local Chunk = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared").ChunkManager.Chunk)
TerrainGen.ServerChunkCache = {} :: {[typeof("")]: typeof(Chunk.new(0,0,0))} 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 -- Load a chunk from the DataStore or generate it if not found
function TerrainGen:GetChunk(x, y, z) function TerrainGen:GetChunk(x, y, z)
local key = `{x},{y},{z}` local key = chunkKeyFromCoords(x, y, z)
if TerrainGen.ServerChunkCache[key] then if TerrainGen.ServerChunkCache[key] then
return TerrainGen.ServerChunkCache[key] return TerrainGen.ServerChunkCache[key]
end end
-- Generate a new chunk if it doesn't exist -- Generate a new chunk if it doesn't exist
local chunk = Chunk.new(x, y, z) local chunk = Chunk.new(x, y, z)
if y == 1 then if y == 1 then
@@ -67,7 +103,6 @@ function TerrainGen:GetFakeChunk(x, y, z)
return chunk return chunk
end end
TerrainGen.CM = ChunkManager TerrainGen.CM = ChunkManager
return TerrainGen return TerrainGen

View File

@@ -139,12 +139,21 @@ local function isBlockInsidePlayer(blockPos: Vector3): boolean
end end
local DEBUG_PLACEMENT = true 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) placeRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockId)
local function reject(reason: string) local function reject(reason: string)
if DEBUG_PLACEMENT then debugPlacementWarn("[PLACE][REJECT]", player.Name, reason, "chunk", cx, cy, cz, "block", x, y, z, "id", blockId)
warn("[PLACE][REJECT]", player.Name, reason, "chunk", cx, cy, cz, "block", x, y, z, "id", blockId)
end
return return
end 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 and existing.id and existing.id ~= 0 then
if existing.id == resolvedId then if existing.id == resolvedId then
-- same block already there; treat as success without changes -- same block already there; treat as success without changes
if DEBUG_PLACEMENT then debugPlacementLog("[PLACE][OK][NOOP]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId)
print("[PLACE][OK][NOOP]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId)
end
return return
end end
-- allow replacement when different id: remove then place -- 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) chunk:CreateBlock(x, y, z, data)
propogate("B_C", cx, cy, cz, x, y, z, data) propogate("B_C", cx, cy, cz, x, y, z, data)
if DEBUG_PLACEMENT then debugPlacementLog("[PLACE][OK]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId)
print("[PLACE][OK]", player.Name, "chunk", cx, cy, cz, "block", x, y, z, "id", resolvedId)
end
end) end)
breakRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z) 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() task.synchronize()
tickRemote:FireClient(player, "C_R", cx, cy, cz, 0, 0, 0, 0) tickRemote:FireClient(player, "C_R", cx, cy, cz, 0, 0, 0, 0)
task.desynchronize() task.desynchronize()
debugPlacementLog("[BREAK][NOOP]", player.Name, "chunk", cx, cy, cz, "block", x, y, z)
return return
end end
chunk:RemoveBlock(x, y, z) chunk:RemoveBlock(x, y, z)
propogate("B_D", cx, cy, cz, x, y, z, 0) propogate("B_D", cx, cy, cz, x, y, z, 0)
debugPlacementLog("[BREAK][OK]", player.Name, "chunk", cx, cy, cz, "block", x, y, z)
end) end)
task.desynchronize() task.desynchronize()

View File

@@ -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,
}

View File

@@ -15,6 +15,7 @@ local Roact = require(ReplicatedStorage.Packages.roact)
local PM = require(ReplicatedStorage.Shared.PlacementManager) local PM = require(ReplicatedStorage.Shared.PlacementManager)
local BlockManager = require(ReplicatedStorage.Shared.ChunkManager.BlockManager) local BlockManager = require(ReplicatedStorage.Shared.ChunkManager.BlockManager)
local PlacementState = require(ReplicatedStorage.Shared.PlacementState) local PlacementState = require(ReplicatedStorage.Shared.PlacementState)
local Util = require(ReplicatedStorage.Shared.Util)
local blocksFolder = ReplicatedStorage:WaitForChild("Blocks") local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")
@@ -56,9 +57,12 @@ local function buildHotbarIds(): {string}
for _, block in ipairs(blocksFolder:GetChildren()) do for _, block in ipairs(blocksFolder:GetChildren()) do
local id = block:GetAttribute("n") local id = block:GetAttribute("n")
if id ~= nil then if id ~= nil then
local idStr = tostring(id) local n = tonumber(id)
table.insert(ids, idStr) if n and n > 0 then
names[idStr] = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name local idStr = tostring(n)
table.insert(ids, idStr)
names[idStr] = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name
end
end end
end end
table.sort(ids, function(a, b) table.sort(ids, function(a, b)
@@ -133,6 +137,11 @@ function Hotbar:init()
local slots, names = buildHotbarIds() local slots, names = buildHotbarIds()
self.state.slots = slots self.state.slots = slots
self.state.names = names 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() self._updateSlots = function()
local nextSlots, nextNames = buildHotbarIds() local nextSlots, nextNames = buildHotbarIds()
@@ -154,21 +163,30 @@ function Hotbar:init()
if id ~= "" and self.state.names then if id ~= "" and self.state.names then
name = self.state.names[id] or id name = self.state.names[id] or id
end end
Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", id, "name", name)
PlacementState:SetSelected(id, name) PlacementState:SetSelected(id, name)
end end
self._handleInput = function(input: InputObject, gameProcessedEvent: boolean) self._handleInput = function(input: InputObject, gameProcessedEvent: boolean)
if gameProcessedEvent or isTextInputFocused() then if isTextInputFocused() then
return return
end end
local slot = keyToSlot[input.KeyCode] local slot = keyToSlot[input.KeyCode]
if slot then if slot then
if gameProcessedEvent then
return
end
self._setSelected(slot) self._setSelected(slot)
return return
end end
if input.UserInputType == Enum.UserInputType.MouseButton1 then 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() local mouseBlock = PM:GetBlockAtMouse()
if not mouseBlock then if not mouseBlock then
return return
@@ -182,14 +200,26 @@ function Hotbar:init()
mouseBlock.block.Z mouseBlock.block.Z
) )
elseif input.UserInputType == Enum.UserInputType.MouseButton2 then 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 if not mouseBlock then
return return
end end
local id = PlacementState:GetSelected() local id = PlacementState:GetSelected()
if not id or id == "" then if not id or id == "" then
Util.StudioWarn("[PLACE][CLIENT][REJECT]", "no selected id")
return return
end end
Util.StudioLog(
"[PLACE][CLIENT][SEND][CLICK]",
"chunk",
mouseBlock.chunk,
"block",
mouseBlock.block,
"id",
id
)
PM:PlaceBlock( PM:PlaceBlock(
mouseBlock.chunk.X, mouseBlock.chunk.X,
mouseBlock.chunk.Y, mouseBlock.chunk.Y,