diff --git a/.codex/AGENTS.md b/.codex/AGENTS.md new file mode 100644 index 0000000..09f5df7 --- /dev/null +++ b/.codex/AGENTS.md @@ -0,0 +1,8 @@ +This project is a Minecraft-like voxel system for Roblox using Rojo. The server +is authoritative over chunks and blocks. Clients only handle input, UI, and +requests. Clients must never create or destroy blocks directly. The server must +validate distance, block type, and target. Shared modules must not reference +Roblox services. + +Keep in mind that to replicate anything across the client-server model or to +write to any instance you must use serial luau diff --git a/src/ReplicatedStorage/Remotes/BreakBlock.rbxmx b/src/ReplicatedStorage/Remotes/BreakBlock.rbxmx new file mode 100644 index 0000000..65fd351 --- /dev/null +++ b/src/ReplicatedStorage/Remotes/BreakBlock.rbxmx @@ -0,0 +1,7 @@ + + + + BreakBlock + + + diff --git a/src/ReplicatedStorage/Remotes/PlaceBlock.rbxmx b/src/ReplicatedStorage/Remotes/PlaceBlock.rbxmx new file mode 100644 index 0000000..1130596 --- /dev/null +++ b/src/ReplicatedStorage/Remotes/PlaceBlock.rbxmx @@ -0,0 +1,7 @@ + + + + PlaceBlock + + + diff --git a/src/ReplicatedStorage/Remotes/init.meta.json b/src/ReplicatedStorage/Remotes/init.meta.json new file mode 100644 index 0000000..6a6c824 --- /dev/null +++ b/src/ReplicatedStorage/Remotes/init.meta.json @@ -0,0 +1,4 @@ +{ + "className": "Folder", + "ignoreUnknownInstances": true +} diff --git a/src/ReplicatedStorage/Shared/ChunkManager/ChunkBuilder.lua b/src/ReplicatedStorage/Shared/ChunkManager/ChunkBuilder.lua index 934bfaa..906adfe 100644 --- a/src/ReplicatedStorage/Shared/ChunkManager/ChunkBuilder.lua +++ b/src/ReplicatedStorage/Shared/ChunkManager/ChunkBuilder.lua @@ -39,17 +39,20 @@ local function propogateNeighboringBlockChanges(cx, cy, cz, x, y, z) local d = c.data[`{x},{y},{z}`] if not d then return end - task.synchronize() - if c:IsBlockRenderable(x, y, z) then if c.instance:FindFirstChild(`{x},{y},{z}`) then return end + task.synchronize() local block = BlockManager:GetBlockRotated(d.id, util.RotationStringToNormalId(d.state["r"] or "f"), d.state) block.Name = `{x},{y},{z}` block:PivotTo(util.ChunkPosToCFrame(c.pos, Vector3.new(x, y, z))) block.Parent = c.instance + task.desynchronize() else - if c.instance:FindFirstChild(`{x},{y},{z}`) then - c.instance:FindFirstChild(`{x},{y},{z}`):Destroy() + local existing = c.instance:FindFirstChild(`{x},{y},{z}`) + if existing then + task.synchronize() + existing:Destroy() + task.desynchronize() end end end diff --git a/src/ReplicatedStorage/Shared/PlacementManager.lua b/src/ReplicatedStorage/Shared/PlacementManager.lua index 7ce9846..96c316f 100644 --- a/src/ReplicatedStorage/Shared/PlacementManager.lua +++ b/src/ReplicatedStorage/Shared/PlacementManager.lua @@ -28,6 +28,55 @@ local function findParent(i: Instance): Instance end local Mouse: Mouse = nil +local lastNormalId: Enum.NormalId? = nil + +local function normalIdToOffset(normal: Enum.NormalId): Vector3 + if normal == Enum.NormalId.Top then + return Vector3.new(0, 1, 0) + elseif normal == Enum.NormalId.Bottom then + return Vector3.new(0, -1, 0) + elseif normal == Enum.NormalId.Left then + return Vector3.new(-1, 0, 0) + elseif normal == Enum.NormalId.Right then + return Vector3.new(1, 0, 0) + elseif normal == Enum.NormalId.Back then + return Vector3.new(0, 0, 1) + elseif normal == Enum.NormalId.Front then + return Vector3.new(0, 0, -1) + end + return Vector3.new(0, 0, 0) +end + +local function offsetChunkBlock(chunk: Vector3, block: Vector3, offset: Vector3) + local cx, cy, cz = chunk.X, chunk.Y, chunk.Z + local bx, by, bz = block.X + offset.X, block.Y + offset.Y, block.Z + offset.Z + + if bx < 1 then + bx = 8 + cx -= 1 + elseif bx > 8 then + bx = 1 + cx += 1 + end + + if by < 1 then + by = 8 + cy -= 1 + elseif by > 8 then + by = 1 + cy += 1 + end + + if bz < 1 then + bz = 8 + cz -= 1 + elseif bz > 8 then + bz = 1 + cz += 1 + end + + return Vector3.new(cx, cy, cz), Vector3.new(bx, by, bz) +end -- Gets the block and normalid of the block (and surface) the player is looking at function PlacementManager:Raycast() @@ -40,6 +89,7 @@ function PlacementManager:Raycast() if not objLookingAt then PlacementManager.SelectionBox.Adornee = nil script.RaycastResult.Value = nil + lastNormalId = nil return end @@ -48,10 +98,12 @@ function PlacementManager:Raycast() if parent:GetAttribute("ns") == true then PlacementManager.SelectionBox.Adornee = nil script.RaycastResult.Value = nil + lastNormalId = nil return end PlacementManager.SelectionBox.Adornee = parent script.RaycastResult.Value = parent + lastNormalId = dir return parent, dir end @@ -59,16 +111,17 @@ function PlacementManager:RaycastGetResult() return script.RaycastResult.Value end -local placeRemote = game:GetService("ReplicatedStorage").PlaceBlock -local breakRemote = game:GetService("ReplicatedStorage").BreakBlock +local remotes = game:GetService("ReplicatedStorage"):WaitForChild("Remotes") +local placeRemote = remotes:WaitForChild("PlaceBlock") +local breakRemote = remotes:WaitForChild("BreakBlock") local tickRemote = game:GetService("ReplicatedStorage").Tick -- FIRES REMOTE -function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockData) +function PlacementManager:PlaceBlock(cx, cy, cz, x, y, z, blockId: string) --print("placeblock") --local chunk = ChunkManager:GetChunk(cx, cy, cz) --chunk:CreateBlock(x, y, z, blockData) - placeRemote:FireServer(cx, cy, cz, x, y, z, blockData) + placeRemote:FireServer(cx, cy, cz, x, y, z, blockId) end -- FIRES REMOTE @@ -97,11 +150,13 @@ function PlacementManager:GetBlockAtMouse(): nil | {chunk:Vector3, block: Vector if selectedPart == nil then PlacementManager.SelectionBox.Adornee = nil script.RaycastResult.Value = nil + lastNormalId = nil return nil end if not selectedPart.Parent then PlacementManager.SelectionBox.Adornee = nil script.RaycastResult.Value = nil + lastNormalId = nil return nil end local chunkCoords = Util.BlockPosStringToCoords(selectedPart.Parent.Name) @@ -114,6 +169,32 @@ function PlacementManager:GetBlockAtMouse(): nil | {chunk:Vector3, block: Vector end +function PlacementManager:GetTargetAtMouse(): nil | {chunk:Vector3, block: Vector3, normal: Enum.NormalId} + local hit = PlacementManager:GetBlockAtMouse() + if not hit or not lastNormalId then + return nil + end + + return { + chunk = hit.chunk, + block = hit.block, + normal = lastNormalId + } +end + +function PlacementManager:GetPlacementAtMouse(): nil | {chunk:Vector3, block: Vector3} + local hit = PlacementManager:GetTargetAtMouse() + if not hit then + return nil + end + local offset = normalIdToOffset(hit.normal) + local placeChunk, placeBlock = offsetChunkBlock(hit.chunk, hit.block, offset) + return { + chunk = placeChunk, + block = placeBlock + } +end + function PlacementManager:Init() game:GetService("RunService").Heartbeat:Connect(function() local a,b = pcall(function() diff --git a/src/ServerScriptService/Actor/ServerChunkManager/TerrainGen/init.lua b/src/ServerScriptService/Actor/ServerChunkManager/TerrainGen/init.lua index 8715741..731a7c8 100644 --- a/src/ServerScriptService/Actor/ServerChunkManager/TerrainGen/init.lua +++ b/src/ServerScriptService/Actor/ServerChunkManager/TerrainGen/init.lua @@ -14,6 +14,10 @@ TerrainGen.ServerChunkCache = {} :: {[typeof("")]: typeof(Chunk.new(0,0,0))} -- Load a chunk from the DataStore or generate it if not found function TerrainGen:GetChunk(x, y, z) + local key = `{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) @@ -38,6 +42,7 @@ function TerrainGen:GetChunk(x, y, z) end end + TerrainGen.ServerChunkCache[key] = chunk return chunk end diff --git a/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua b/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua index 2d309a4..18f291e 100644 --- a/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua +++ b/src/ServerScriptService/Actor/ServerChunkManager/init.server.lua @@ -8,7 +8,7 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage") local Shared = ReplicatedStorage:WaitForChild("Shared") local ModsFolder = ReplicatedStorage:WaitForChild("Mods") -local PlacementManager = require(Shared.PlacementManager) +local Util = require(Shared.Util) local TG = require("./ServerChunkManager/TerrainGen") do @@ -64,11 +64,67 @@ ReplicatedStorage.RecieveChunkPacket.OnServerInvoke = function(plr: Player, x: n end local tickRemote = ReplicatedStorage.Tick +local remotes = ReplicatedStorage:WaitForChild("Remotes") +local placeRemote = remotes:WaitForChild("PlaceBlock") +local breakRemote = remotes:WaitForChild("BreakBlock") +local blocksFolder = ReplicatedStorage:WaitForChild("Blocks") local function propogate(a, cx, cy, cz, x, y, z, bd) + task.synchronize() tickRemote:FireAllClients(a, cx, cy, cz, x, y, z, bd) + task.desynchronize() end -ReplicatedStorage.PlaceBlock.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockData) +local MAX_REACH = 24 +local blockIdMap = {} + +local function rebuildBlockIdMap() + table.clear(blockIdMap) + for _, block in ipairs(blocksFolder:GetChildren()) do + local id = block:GetAttribute("n") + if id ~= nil then + blockIdMap[id] = id + blockIdMap[tostring(id)] = id + end + end +end + +rebuildBlockIdMap() +blocksFolder.ChildAdded:Connect(rebuildBlockIdMap) +blocksFolder.ChildRemoved:Connect(rebuildBlockIdMap) + +local function getPlayerPosition(player: Player): Vector3? + local character = player.Character + if not character then + return nil + end + local root = character:FindFirstChild("HumanoidRootPart") + if not root then + return nil + end + return root.Position +end + +local function isWithinReach(player: Player, cx: number, cy: number, cz: number, x: number, y: number, z: number): boolean + local playerPos = getPlayerPosition(player) + if not playerPos then + return false + end + local blockPos = Util.ChunkPosToCFrame(Vector3.new(cx, cy, cz), Vector3.new(x, y, z)).Position + return (blockPos - playerPos).Magnitude <= MAX_REACH +end + +local function resolveBlockId(blockId: any): string | number | nil + return blockIdMap[blockId] +end + +local function getServerChunk(cx: number, cy: number, cz: number) + task.desynchronize() + local chunk = TG:GetChunk(cx, cy, cz) + task.synchronize() + return chunk +end + +placeRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockId) --print("place",player, cx, cy, cz, x, y, z, blockData) if typeof(cx) ~= "number" or typeof(cy) ~= "number" or typeof(cz) ~= "number" then @@ -77,16 +133,33 @@ ReplicatedStorage.PlaceBlock.OnServerEvent:Connect(function(player, cx, cy, cz, if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then return end + if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then + return + end if math.abs(cx) > MAX_CHUNK_DIST or math.abs(cy) > MAX_CHUNK_DIST or math.abs(cz) > MAX_CHUNK_DIST then --return end + if not isWithinReach(player, cx, cy, cz, x, y, z) then + return + end + local resolvedId = resolveBlockId(blockId) + if not resolvedId then + return + end - --local chunk = TG:GetChunk(cx, cy, cz) - --PlacementManager:PlaceBlockLocal(cx, cy, cz, x, y, z, blockData) - propogate("B_C", cx, cy, cz, x, y, z, blockData) + local chunk = getServerChunk(cx, cy, cz) + if chunk:GetBlockAt(x, y, z) then + return + end + local data = { + id = resolvedId, + state = {} + } + chunk:CreateBlock(x, y, z, data) + propogate("B_C", cx, cy, cz, x, y, z, data) end) -ReplicatedStorage.BreakBlock.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z) +breakRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z) --print("del",player, cx, cy, cz, x, y, z) if typeof(cx) ~= "number" or typeof(cy) ~= "number" or typeof(cz) ~= "number" then @@ -95,13 +168,22 @@ ReplicatedStorage.BreakBlock.OnServerEvent:Connect(function(player, cx, cy, cz, if typeof(x) ~= "number" or typeof(y) ~= "number" or typeof(z) ~= "number" then return end + if x < 1 or x > 8 or y < 1 or y > 8 or z < 1 or z > 8 then + return + end if math.abs(cx) > MAX_CHUNK_DIST or math.abs(cy) > MAX_CHUNK_DIST or math.abs(cz) > MAX_CHUNK_DIST then return end + if not isWithinReach(player, cx, cy, cz, x, y, z) then + return + end - --local chunk = TG:GetChunk(cx, cy, cz) - --PlacementManager:BreakBlockLocal(cx, cy, cz, x, y, z) + local chunk = getServerChunk(cx, cy, cz) + if not chunk:GetBlockAt(x, y, z) then + return + end + chunk:RemoveBlock(x, y, z) propogate("B_D", cx, cy, cz, x, y, z, 0) end) -task.desynchronize() \ No newline at end of file +task.desynchronize() diff --git a/src/StarterGui/Game_UI/LocalScript.client.lua b/src/StarterGui/Game_UI/LocalScript.client.lua index 7b92ec6..0b92109 100644 --- a/src/StarterGui/Game_UI/LocalScript.client.lua +++ b/src/StarterGui/Game_UI/LocalScript.client.lua @@ -5,7 +5,6 @@ end local ui = script.Parent local ReplicatedStorage = game:GetService("ReplicatedStorage") -local UIS = game:GetService("UserInputService") ReplicatedStorage:WaitForChild("Objects"):WaitForChild("MLLoaded") @@ -18,22 +17,6 @@ cd.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain") sky.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain") base.Parent = game:GetService("Workspace"):FindFirstChildOfClass("Terrain") -local PM = require(ReplicatedStorage.Shared.PlacementManager) - -UIS.InputEnded:Connect(function(input: InputObject, gameProcessedEvent: boolean) - if input.UserInputType == Enum.UserInputType.MouseButton1 then - local mouseBlock = PM:GetBlockAtMouse() - if not mouseBlock then return end - PM:BreakBlock(mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z) - elseif input.UserInputType == Enum.UserInputType.MouseButton2 then - local mouseBlock = PM:GetBlockAtMouse() - if not mouseBlock then return end - PM:PlaceBlock(mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z, { - id = 2, - state = {} - }) - end -end) game:GetService("RunService").RenderStepped:Connect(function(dt) local fps = math.round(1/dt) @@ -76,4 +59,4 @@ game:GetService("RunService").RenderStepped:Connect(function(dt) ) end) -end) \ No newline at end of file +end) diff --git a/src/StarterPlayer/StarterPlayerScripts/Actor/BlockInteraction.client.lua b/src/StarterPlayer/StarterPlayerScripts/Actor/BlockInteraction.client.lua new file mode 100644 index 0000000..2af7785 --- /dev/null +++ b/src/StarterPlayer/StarterPlayerScripts/Actor/BlockInteraction.client.lua @@ -0,0 +1,92 @@ +if not game:IsLoaded() then + game.Loaded:Wait() +end + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local UIS = game:GetService("UserInputService") + +ReplicatedStorage:WaitForChild("Objects"):WaitForChild("MLLoaded") + +local blocksFolder = ReplicatedStorage:WaitForChild("Blocks") +local PM = require(ReplicatedStorage.Shared.PlacementManager) + +local HOTBAR_SIZE = 9 +local hotbar = table.create(HOTBAR_SIZE) +local selectedSlot = 1 + +local keyToSlot = { + [Enum.KeyCode.One] = 1, + [Enum.KeyCode.Two] = 2, + [Enum.KeyCode.Three] = 3, + [Enum.KeyCode.Four] = 4, + [Enum.KeyCode.Five] = 5, + [Enum.KeyCode.Six] = 6, + [Enum.KeyCode.Seven] = 7, + [Enum.KeyCode.Eight] = 8, + [Enum.KeyCode.Nine] = 9, +} + +local function rebuildHotbar() + local ids = {} + for _, block in ipairs(blocksFolder:GetChildren()) do + local id = block:GetAttribute("n") + if id ~= nil then + table.insert(ids, tostring(id)) + end + end + + table.sort(ids) + for i = 1, HOTBAR_SIZE do + hotbar[i] = ids[i] or "" + end + selectedSlot = math.clamp(selectedSlot, 1, HOTBAR_SIZE) +end + +local function getSelectedBlockId(): string? + local id = hotbar[selectedSlot] + if id == "" then + return nil + end + return id +end + +local function setSelectedSlot(slot: number) + if slot < 1 or slot > HOTBAR_SIZE then + return + end + selectedSlot = slot +end + +rebuildHotbar() +blocksFolder.ChildAdded:Connect(rebuildHotbar) +blocksFolder.ChildRemoved:Connect(rebuildHotbar) + +UIS.InputBegan:Connect(function(input: InputObject, gameProcessedEvent: boolean) + if gameProcessedEvent then + return + end + + local slot = keyToSlot[input.KeyCode] + if slot then + setSelectedSlot(slot) + return + end + + if input.UserInputType == Enum.UserInputType.MouseButton1 then + local mouseBlock = PM:GetBlockAtMouse() + if not mouseBlock then + return + end + PM:BreakBlock(mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z) + elseif input.UserInputType == Enum.UserInputType.MouseButton2 then + local mouseBlock = PM:GetPlacementAtMouse() + if not mouseBlock then + return + end + local blockId = getSelectedBlockId() + if not blockId then + return + end + PM:PlaceBlock(mouseBlock.chunk.X, mouseBlock.chunk.Y, mouseBlock.chunk.Z, mouseBlock.block.X, mouseBlock.block.Y, mouseBlock.block.Z, blockId) + end +end) diff --git a/src/StarterPlayer/StarterPlayerScripts/Actor/Init.client.lua b/src/StarterPlayer/StarterPlayerScripts/Actor/Init.client.lua index b4f614a..deccdd2 100644 --- a/src/StarterPlayer/StarterPlayerScripts/Actor/Init.client.lua +++ b/src/StarterPlayer/StarterPlayerScripts/Actor/Init.client.lua @@ -3,6 +3,7 @@ if not game:IsLoaded() then end pcall(function() + task.synchronize() game:GetService("Workspace"):WaitForChild("$blockscraft_server",5):Destroy() end) @@ -22,4 +23,4 @@ end do local CM = require(ReplicatedStorage:WaitForChild("Shared"):WaitForChild("ChunkManager")) CM:Init() -end \ No newline at end of file +end