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