Files
block-game/ReplicatedStorage/Shared/ChunkManager/init.lua
2026-01-09 17:02:35 +02:00

546 lines
13 KiB
Lua

--!native
--!optimize 2
local ChunkManager = {}
local RunService = game:GetService("RunService")
local Chunk = require("./ChunkManager/Chunk")
local BlockManager = require("./ChunkManager/BlockManager")
local ChunkBuilder = require("./ChunkManager/ChunkBuilder")
local Util = require("./Util")
local Globals = require(script.Parent:WaitForChild("Globals"))
local remote = game:GetService("ReplicatedStorage"):WaitForChild("RecieveChunkPacket")
local tickremote = game:GetService("ReplicatedStorage"):WaitForChild("Tick")
local Players = game:GetService("Players")
local ChunkFolder = Instance.new("Folder")
ChunkFolder.Name = "$blockscraft_client"
ChunkManager.ChunkFolder = ChunkFolder
local CHUNK_RADIUS = Globals.RenderDistance or 5
local LOAD_BATCH = Globals.LoadBatch or 8
local RESYNC_INTERVAL = Globals.ResyncInterval or 5
local RESYNC_RADIUS = Globals.ResyncRadius or 2
local DEBUG_RESYNC = false
local FORCELOAD_CHUNKS = {
{0, 1, 0}
}
local unloadingChunks = {}
local pendingChunkRequests = {}
local lastChunkKey: string? = nil
local lastHeavyTick = 0
local HEAVY_TICK_INTERVAL = 1.5
local lastUnloadSweep = 0
local UNLOAD_SWEEP_INTERVAL = 3 -- slower sweep cadence
local MAX_LOADED_CHUNKS = 0
local SPAWN_CHUNK_KEY: string? = nil
local playerFrozen = false
local storedMovementState = nil
local function worldToChunkCoord(v: number): number
return math.floor((v + 16) / 32)
end
local CHUNK_OFFSETS = {}
do
for y = -CHUNK_RADIUS, CHUNK_RADIUS do
for x = -CHUNK_RADIUS, CHUNK_RADIUS do
for z = -CHUNK_RADIUS, CHUNK_RADIUS do
table.insert(CHUNK_OFFSETS, {x, y, z, (x * x) + (y * y) + (z * z)})
end
end
end
table.sort(CHUNK_OFFSETS, function(a, b)
return a[4] < b[4]
end)
MAX_LOADED_CHUNKS = math.max(1, math.floor(#CHUNK_OFFSETS * 2)) -- tighter cap than full render cube
if FORCELOAD_CHUNKS[1] then
local forced = FORCELOAD_CHUNKS[1]
SPAWN_CHUNK_KEY = `{forced[1]},{forced[2]},{forced[3]}`
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()
end
end
local function setCharacterFrozen(shouldFreeze: boolean)
local player = Players.LocalPlayer
if not player then
return
end
local character = player.Character
if not character then
return
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
local root = character:FindFirstChild("HumanoidRootPart")
if not humanoid or not root then
return
end
if shouldFreeze == playerFrozen then
return
end
if shouldFreeze then
if not storedMovementState then
storedMovementState = {
walkSpeed = humanoid.WalkSpeed,
autoRotate = humanoid.AutoRotate,
}
if humanoid.UseJumpPower then
storedMovementState.jumpPower = humanoid.JumpPower
else
storedMovementState.jumpHeight = humanoid.JumpHeight
end
end
humanoid.AutoRotate = false
humanoid.WalkSpeed = 0
if humanoid.UseJumpPower then
humanoid.JumpPower = 0
else
humanoid.JumpHeight = 0
end
root.Anchored = true
else
root.Anchored = false
if storedMovementState then
humanoid.AutoRotate = storedMovementState.autoRotate
humanoid.WalkSpeed = storedMovementState.walkSpeed
if humanoid.UseJumpPower and storedMovementState.jumpPower then
humanoid.JumpPower = storedMovementState.jumpPower
elseif storedMovementState.jumpHeight then
humanoid.JumpHeight = storedMovementState.jumpHeight
end
end
storedMovementState = nil
end
playerFrozen = shouldFreeze
end
local function getLocalPlayerChunkPos()
local player = Players.LocalPlayer
if not player then
return nil
end
local character = player.Character
if not character then
return nil
end
local root = character:FindFirstChild("HumanoidRootPart")
if not root then
return nil
end
local pos = root.Position
return {
x = worldToChunkCoord(pos.X),
y = worldToChunkCoord(pos.Y),
z = worldToChunkCoord(pos.Z)
}
end
local function isWithinRenderDistance(chunkPos: Vector3, centerChunkPos): boolean
if not centerChunkPos then
return false
end
return math.abs(chunkPos.X - centerChunkPos.x) <= CHUNK_RADIUS
and math.abs(chunkPos.Y - centerChunkPos.y) <= CHUNK_RADIUS
and math.abs(chunkPos.Z - centerChunkPos.z) <= CHUNK_RADIUS
end
local function shouldSkipUnload(key: string): boolean
return SPAWN_CHUNK_KEY ~= nil and key == SPAWN_CHUNK_KEY
end
local function scheduleChunkUnload(key: string, chunk)
if not chunk or unloadingChunks[key] then
return
end
unloadingChunks[key] = true
task.defer(function()
chunk:Unload()
chunk:Destroy()
Chunk.AllChunks[key] = nil
unloadingChunks[key] = nil
end)
end
local function evictOutOfRangeChunks(centerChunkPos)
if not centerChunkPos then
return
end
local loadedCount = 0
for key, loadedChunk in pairs(Chunk.AllChunks) do
if loadedChunk.loaded and not shouldSkipUnload(key) then
local inRange = isWithinRenderDistance(loadedChunk.pos, centerChunkPos)
if inRange then
loadedCount += 1
if loadedCount > MAX_LOADED_CHUNKS then
scheduleChunkUnload(key, loadedChunk)
end
else
scheduleChunkUnload(key, loadedChunk)
end
end
end
end
function ChunkManager:GetChunk(x, y, z)
local key = `{x},{y},{z}`
if Chunk.AllChunks[key] then
return Chunk.AllChunks[key]
end
if pendingChunkRequests[key] then
while pendingChunkRequests[key] do
task.wait()
end
return Chunk.AllChunks[key]
end
pendingChunkRequests[key] = true
local ok, data = pcall(function()
return remote:InvokeServer(x, y, z)
end)
if not ok then
data = {}
end
local ch = Chunk.from(x, y, z, data)
Chunk.AllChunks[key] = ch
pendingChunkRequests[key] = nil
return ch
end
local function ensureNeighboringChunksLoaded(x, y, z)
local offsets = {
{1, 0, 0}, {-1, 0, 0},
{0, 1, 0}, {0, -1, 0},
{0, 0, 1}, {0, 0, -1}
}
for _, offset in ipairs(offsets) do
local nx, ny, nz = x + offset[1], y + offset[2], z + offset[3]
local neighbor = ChunkManager:GetChunk(nx, ny, nz)
if neighbor then
neighbor:Tick()
end
end
end
function ChunkManager:LoadChunk(x, y, z)
local key = `{x},{y},{z}`
if unloadingChunks[key] or not Chunk.AllChunks[key] or Chunk.AllChunks[key].loaded then
return
end
unloadingChunks[key] = true
task.defer(function()
ensureNeighboringChunksLoaded(x, y, z)
local chunk = Chunk.AllChunks[key]
if not chunk then
chunk = ChunkManager:GetChunk(x, y, z)
Chunk.AllChunks[key] = chunk
end
local instance = ChunkBuilder:BuildChunk(chunk, ChunkFolder)
chunk.instance = instance
chunk.loaded = true
unloadingChunks[key] = nil
evictOutOfRangeChunks(getLocalPlayerChunkPos())
end)
end
function ChunkManager:RefreshChunk(x, y, z)
local key = `{x},{y},{z}`
local chunk = Chunk.AllChunks[key]
if not chunk then
pendingChunkRequests[key] = nil
ChunkManager:GetChunk(x, y, z)
ChunkManager:LoadChunk(x, y, z)
return
end
local ok, newData = pcall(function()
return remote:InvokeServer(x, y, z)
end)
if not ok or typeof(newData) ~= "table" then
if DEBUG_RESYNC then
warn("[CHUNKMANAGER][RESYNC] Failed to fetch chunk data", key, ok, typeof(newData))
end
return
end
local function sameState(a, b)
if a == b then
return true
end
if not a or not b then
return false
end
local count = 0
for k, v in pairs(a) do
count += 1
if b[k] ~= v then
return false
end
end
for _ in pairs(b) do
count -= 1
end
return count == 0
end
local function sameBlockData(a, b)
if a == b then
return true
end
if not a or not b then
return false
end
if a.id ~= b.id then
return false
end
return sameState(a.state, b.state)
end
local changed = 0
local removed = 0
for keyStr, newBlock in pairs(newData) do
local oldBlock = chunk.data[keyStr]
if not sameBlockData(oldBlock, newBlock) then
chunk.data[keyStr] = newBlock
local coords = Util.BlockPosStringToCoords(keyStr)
chunk:PropogateChanges(coords.X, coords.Y, coords.Z, newBlock)
changed += 1
end
end
for keyStr in pairs(chunk.data) do
if not newData[keyStr] then
chunk.data[keyStr] = nil
local coords = Util.BlockPosStringToCoords(keyStr)
chunk:PropogateChanges(coords.X, coords.Y, coords.Z, 0)
removed += 1
end
end
if chunk.loaded and chunk.instance then
local pruned = 0
for _, child in ipairs(chunk.instance:GetChildren()) do
if not newData[child.Name] then
pruned += 1
child:Destroy()
end
end
if DEBUG_RESYNC and pruned > 0 then
print("[CHUNKMANAGER][RESYNC] Pruned ghost instances", key, "count", pruned)
end
end
if DEBUG_RESYNC and (changed > 0 or removed > 0) then
print("[CHUNKMANAGER][RESYNC] Applied diff", key, "changed", changed, "removed", removed)
end
end
function ChunkManager:ForceTick()
for _, coords in ipairs(FORCELOAD_CHUNKS) do
local key = `{coords[1]},{coords[2]},{coords[3]}`
local chunk = Chunk.AllChunks[key]
if not chunk then
ChunkManager:LoadChunk(coords[1], coords[2], coords[3])
else
chunk:Tick()
end
end
end
function ChunkManager:TickI()
for key, chunk in pairs(Chunk.AllChunks) do
if tick() - chunk.inhabitedTime <= 5 then
tickremote:FireServer(key)
end
end
end
function ChunkManager:Tick()
ChunkManager:ForceTick()
local player = Players.LocalPlayer
if not player.Character then
return
end
local pos = player.Character:GetPivot().Position
local chunkPos = {
x = worldToChunkCoord(pos.X),
y = worldToChunkCoord(pos.Y),
z = worldToChunkCoord(pos.Z)
}
local ck = `{chunkPos.x},{chunkPos.y},{chunkPos.z}`
local currentChunk = Chunk.AllChunks[ck]
local now = tick()
local shouldHeavyTick = (ck ~= lastChunkKey) or (now - lastHeavyTick >= HEAVY_TICK_INTERVAL)
lastChunkKey = ck
if shouldHeavyTick then
lastHeavyTick = now
end
setCharacterFrozen(not (currentChunk and currentChunk.loaded))
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)
if chunk then
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
end)
else
if currentChunk then
currentChunk.inhabitedTime = now
end
end
--[[
task.defer(function()
for y = 0, 2 do
task.defer(function()
for x = -CHUNK_RADIUS, CHUNK_RADIUS do
for z = -CHUNK_RADIUS, CHUNK_RADIUS do
local cx, cy, cz = chunkPos.x + x, y, chunkPos.z + z
local key = `{cx},{cy},{cz}`
local chunk = ChunkManager:GetChunk(cx, cy, cz)
chunk.inhabitedTime = tick()
if not chunk.loaded then
ChunkManager:LoadChunk(cx, cy, cz)
Swait(2)
end
end
end
end)
Swait(10)
end
end)
--]]
if now - lastUnloadSweep >= UNLOAD_SWEEP_INTERVAL then
lastUnloadSweep = now
for key, loadedChunk in pairs(Chunk.AllChunks) do
if now - loadedChunk.inhabitedTime > 30 and not unloadingChunks[key] and not shouldSkipUnload(key) then -- keep chunks around longer before unloading
scheduleChunkUnload(key, loadedChunk)
end
end
end
end
function ChunkManager:ResyncAroundPlayer(radius: number)
local player = game:GetService("Players").LocalPlayer
if not player.Character then
return
end
local pos = player.Character:GetPivot().Position
local chunkPos = {
x = worldToChunkCoord(pos.X),
y = worldToChunkCoord(pos.Y),
z = worldToChunkCoord(pos.Z)
}
for y = -radius, radius do
for x = -radius, radius do
for z = -radius, radius do
local cx, cy, cz = chunkPos.x + x, chunkPos.y + y, chunkPos.z + z
ChunkManager:RefreshChunk(cx, cy, cz)
end
end
end
end
function ChunkManager:ResyncAroundChunk(cx: number, cy: number, cz: number, radius: number)
for y = -radius, radius do
for x = -radius, radius do
for z = -radius, radius do
ChunkManager:RefreshChunk(cx + x, cy + y, cz + z)
end
end
end
end
function ChunkManager:Init()
if not RunService:IsClient() then
error("ChunkManager:Init can only be called on the client")
end
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)
ChunkManager:TickI()
end
end)
task.defer(function()
while true do
task.defer(function()
local success, err = pcall(function()
ChunkManager:Tick()
end)
if not success then
warn("[CHUNKMANAGER]", err)
end
end)
Swait(20)
end
end)
task.defer(function()
while true do
wait(RESYNC_INTERVAL)
local ok, err = pcall(function()
ChunkManager:ResyncAroundPlayer(RESYNC_RADIUS)
end)
if not ok then
warn("[CHUNKMANAGER][RESYNC]", err)
end
end
end)
end
return ChunkManager