diff --git a/.gitignore b/.gitignore index 0b82408..2ec20ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -Packages/ \ No newline at end of file +Packages/ +ServerPackages/ \ No newline at end of file diff --git a/ReplicatedStorage/Shared/ClientState.lua b/ReplicatedStorage/Shared/ClientState.lua new file mode 100644 index 0000000..bfc8a0b --- /dev/null +++ b/ReplicatedStorage/Shared/ClientState.lua @@ -0,0 +1,128 @@ +--!native +--!optimize 2 + +local RunService = game:GetService("RunService") + +if RunService:IsServer() then + error("ClientState can only be required on the client") +end + +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Replica = require(ReplicatedStorage.Packages.replica) + +local ClientState = {} + +local HOTBAR_SIZE = 10 + +local localPlayer = Players.LocalPlayer +local replicaForPlayer = nil +local changed = Instance.new("BindableEvent") + +local function fireChanged() + changed:Fire() +end + +local function onReplicaNew(replica) + local tags = replica.Tags or {} + if tags.UserId ~= localPlayer.UserId and tags.Player ~= localPlayer then + return + end + + replicaForPlayer = replica + replica:OnChange(fireChanged) + fireChanged() +end + +Replica.OnNew("ClientState", onReplicaNew) +Replica.RequestData() + +function ClientState:IsReady(): boolean + return replicaForPlayer ~= nil +end + +function ClientState:GetReplica() + return replicaForPlayer +end + +function ClientState:GetSelectedSlot(): number? + if not replicaForPlayer then + return nil + end + return replicaForPlayer.Data.selectedSlot +end + +local function getInventory() + return replicaForPlayer and replicaForPlayer.Data.inventory or nil +end + +function ClientState:GetItemInfo(blockId: any) + if not replicaForPlayer or not blockId then + return nil + end + local inv = getInventory() + local entry = inv and inv[tostring(blockId)] + if not entry then + return nil + end + + return { + id = tostring(blockId), + name = entry.name or tostring(blockId), + count = entry.count, + } +end + +function ClientState:GetHotbarSlots(): {string} + if not replicaForPlayer then + local slots = table.create(HOTBAR_SIZE) + for i = 1, HOTBAR_SIZE do + slots[i] = "" + end + return slots + end + + return replicaForPlayer.Data.hotbar or {} +end + +function ClientState:GetSlotInfo(slot: number) + if not replicaForPlayer then + return nil + end + local hotbar = replicaForPlayer.Data.hotbar + if not hotbar then + return nil + end + local id = hotbar[slot] + if not id then + return nil + end + return ClientState:GetItemInfo(id) +end + +function ClientState:GetSelectedBlock() + if not replicaForPlayer then + return nil + end + local slot = ClientState:GetSelectedSlot() + if not slot then + return nil + end + return ClientState:GetSlotInfo(slot) +end + +function ClientState:SetSelectedSlot(slot: number) + if not replicaForPlayer then + return + end + local hotbar = replicaForPlayer.Data.hotbar + if not hotbar or not hotbar[slot] then + return + end + replicaForPlayer:FireServer("SelectHotbarSlot", slot) +end + +ClientState.Changed = changed.Event + +return ClientState diff --git a/ServerScriptService/Actor/ClientState.lua b/ServerScriptService/Actor/ClientState.lua new file mode 100644 index 0000000..378a68c --- /dev/null +++ b/ServerScriptService/Actor/ClientState.lua @@ -0,0 +1,198 @@ +--!native +--!optimize 2 + +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Replica = require(ReplicatedStorage.Packages.replica) + +local ClientStateService = {} + +local HOTBAR_SIZE = 10 + +local token = Replica.Token("ClientState") + +local blockCatalog = {} +local playerReplicas = {} :: {[Player]: any} +local blocksFolder: Folder? = nil +local readyConnections = {} :: {[Player]: RBXScriptConnection} + +local function sortBlocks() + table.sort(blockCatalog, function(a, b) + local na = tonumber(a.id) + local nb = tonumber(b.id) + if na and nb then + return na < nb + end + if na then + return true + end + if nb then + return false + end + return a.id < b.id + end) +end + +local function rebuildBlockCatalog() + table.clear(blockCatalog) + if not blocksFolder then + return + end + + for _, block in ipairs(blocksFolder:GetChildren()) do + local id = block:GetAttribute("n") + if id ~= nil then + table.insert(blockCatalog, { + id = tostring(id), + name = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name, + }) + end + end + sortBlocks() +end + +local function makeBaseState() + local inventory = {} + local hotbar = {} + + for _, entry in ipairs(blockCatalog) do + inventory[entry.id] = { + name = entry.name, + count = 999999, + } + if #hotbar < HOTBAR_SIZE then + table.insert(hotbar, entry.id) + end + end + + return { + inventory = inventory, + hotbar = hotbar, + selectedSlot = #hotbar > 0 and 1 or 0, + } +end + +local function sanitizeSelection(hotbar, selectedSlot) + if type(selectedSlot) ~= "number" then + return (#hotbar > 0) and 1 or 0 + end + if selectedSlot < 1 or selectedSlot > HOTBAR_SIZE then + return (#hotbar > 0) and 1 or 0 + end + if not hotbar[selectedSlot] then + return (#hotbar > 0) and 1 or 0 + end + return selectedSlot +end + +local function refreshReplica(replica) + local state = makeBaseState() + replica:Set({"inventory"}, state.inventory) + replica:Set({"hotbar"}, state.hotbar) + replica:Set({"selectedSlot"}, sanitizeSelection(state.hotbar, replica.Data.selectedSlot)) +end + +function ClientStateService:SetBlocksFolder(folder: Folder?) + blocksFolder = folder + rebuildBlockCatalog() + for _, replica in pairs(playerReplicas) do + refreshReplica(replica) + end +end + +function ClientStateService:GetReplica(player: Player) + return playerReplicas[player] +end + +function ClientStateService:GetSelectedBlockId(player: Player) + local replica = playerReplicas[player] + if not replica then + return nil + end + local data = replica.Data + local hotbar = data.hotbar or {} + local selectedSlot = sanitizeSelection(hotbar, data.selectedSlot) + return hotbar[selectedSlot] +end + +function ClientStateService:HasInInventory(player: Player, blockId: any): boolean + local replica = playerReplicas[player] + if not replica or not blockId then + return false + end + local inv = replica.Data.inventory + return inv and inv[tostring(blockId)] ~= nil or false +end + +local function handleReplicaEvents(player: Player, replica) + replica.OnServerEvent:Connect(function(plr, action, payload) + if plr ~= player then + return + end + + if action == "SelectHotbarSlot" then + local slot = tonumber(payload) + local hotbar = replica.Data.hotbar + if not hotbar then + return + end + if slot and slot >= 1 and slot <= HOTBAR_SIZE and hotbar[slot] then + replica:Set({"selectedSlot"}, slot) + end + end + end) +end + +local function onPlayerAdded(player: Player) + local replica = Replica.New({ + Token = token, + Tags = { + UserId = player.UserId, + Player = player, + }, + Data = makeBaseState(), + }) + + if Replica.ReadyPlayers[player] then + replica:Subscribe(player) + else + readyConnections[player] = Replica.NewReadyPlayer:Connect(function(newPlayer) + if newPlayer ~= player then + return + end + if readyConnections[player] then + readyConnections[player]:Disconnect() + readyConnections[player] = nil + end + replica:Subscribe(player) + end) + end + + handleReplicaEvents(player, replica) + playerReplicas[player] = replica +end + +local function onPlayerRemoving(player: Player) + local replica = playerReplicas[player] + if replica then + replica:Destroy() + playerReplicas[player] = nil + end + if readyConnections[player] then + readyConnections[player]:Disconnect() + readyConnections[player] = nil + end +end + +function ClientStateService:Init() + rebuildBlockCatalog() + + for _, player in ipairs(Players:GetPlayers()) do + onPlayerAdded(player) + end + Players.PlayerAdded:Connect(onPlayerAdded) + Players.PlayerRemoving:Connect(onPlayerRemoving) +end + +return ClientStateService diff --git a/ServerScriptService/Actor/ServerChunkManager/init.server.lua b/ServerScriptService/Actor/ServerChunkManager/init.server.lua index 62198c7..0848a8e 100644 --- a/ServerScriptService/Actor/ServerChunkManager/init.server.lua +++ b/ServerScriptService/Actor/ServerChunkManager/init.server.lua @@ -2,15 +2,39 @@ --!optimize 2 local ReplicatedStorage = game:GetService("ReplicatedStorage") - +local ServerStorage = game:GetService("ServerStorage") +local ClientStateService = require(script.Parent.ClientState) local Shared = ReplicatedStorage:WaitForChild("Shared") local ModsFolder = ReplicatedStorage:WaitForChild("Mods") +local BlocksFolderRS = ReplicatedStorage:FindFirstChild("Blocks") or Instance.new("Folder") +BlocksFolderRS.Name = "Blocks" +BlocksFolderRS.Parent = ReplicatedStorage +local BlocksFolderSS = ServerStorage:FindFirstChild("Blocks") or Instance.new("Folder") +BlocksFolderSS.Name = "Blocks" +BlocksFolderSS.Parent = ServerStorage local Util = require(Shared.Util) -local TG = require("./ServerChunkManager/TerrainGen") +local TG = require(script.TerrainGen) local Players = game:GetService("Players") +local blockIdMap = {} +local rebuildBlockIdMap + +local function syncBlocksToServerStorage() + BlocksFolderSS:ClearAllChildren() + for _, child in ipairs(BlocksFolderRS:GetChildren()) do + child:Clone().Parent = BlocksFolderSS + end + ClientStateService:SetBlocksFolder(BlocksFolderSS) + if rebuildBlockIdMap then + rebuildBlockIdMap() + end +end + +BlocksFolderRS.ChildAdded:Connect(syncBlocksToServerStorage) +BlocksFolderRS.ChildRemoved:Connect(syncBlocksToServerStorage) + do local workspaceModFolder = game:GetService("Workspace"):WaitForChild("mods") @@ -22,6 +46,8 @@ end local ML = require(Shared.ModLoader) ML.loadModsS() +syncBlocksToServerStorage() +ClientStateService:Init() do local bv = Instance.new("BoolValue") @@ -67,7 +93,7 @@ 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 blocksFolder = BlocksFolderSS local function propogate(a, cx, cy, cz, x, y, z, bd) task.synchronize() tickRemote:FireAllClients(a, cx, cy, cz, x, y, z, bd) @@ -75,9 +101,8 @@ local function propogate(a, cx, cy, cz, x, y, z, bd) end local MAX_REACH = 512 -local blockIdMap = {} -local function rebuildBlockIdMap() +rebuildBlockIdMap = function() table.clear(blockIdMap) for _, block in ipairs(blocksFolder:GetChildren()) do local id = block:GetAttribute("n") @@ -113,6 +138,17 @@ local function resolveBlockId(blockId: any): string | number | nil return blockIdMap[blockId] end +local function playerCanUseBlock(player: Player, resolvedId: any): boolean + if not ClientStateService:HasInInventory(player, resolvedId) then + return false + end + local selected = ClientStateService:GetSelectedBlockId(player) + if not selected then + return false + end + return tostring(selected) == tostring(resolvedId) +end + local function getServerChunk(cx: number, cy: number, cz: number) task.desynchronize() local chunk = TG:GetChunk(cx, cy, cz) @@ -176,6 +212,9 @@ placeRemote.OnServerEvent:Connect(function(player, cx, cy, cz, x, y, z, blockId) if not resolvedId then return reject("invalid id") end + if not playerCanUseBlock(player, resolvedId) then + return reject("not in inventory/hotbar") + end local blockPos = Util.ChunkPosToCFrame(Vector3.new(cx, cy, cz), Vector3.new(x, y, z)).Position if isBlockInsidePlayer(blockPos) then diff --git a/StarterGui/Hotbar/LocalScript.client.lua b/StarterGui/Hotbar/LocalScript.client.lua index 756c6d1..b8dbbaf 100644 --- a/StarterGui/Hotbar/LocalScript.client.lua +++ b/StarterGui/Hotbar/LocalScript.client.lua @@ -18,8 +18,7 @@ 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") +local ClientState = require(ReplicatedStorage.Shared.ClientState) local HOTBAR_SIZE = 10 @@ -53,33 +52,34 @@ local function isTextInputFocused(): boolean return config ~= nil and config.IsFocused end -local function buildHotbarIds(): {string} - local ids = {} - local names = {} - for _, block in ipairs(blocksFolder:GetChildren()) do - local id = block:GetAttribute("n") - if id ~= nil then - 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 +local function resolveSelectedSlot(slots, desired) + if desired and desired >= 1 and desired <= HOTBAR_SIZE and slots[desired] ~= "" then + return desired end - table.sort(ids, function(a, b) - local na = tonumber(a) - local nb = tonumber(b) - if na and nb then - return na < nb - end - return a < b - end) - local slots = table.create(HOTBAR_SIZE) for i = 1, HOTBAR_SIZE do - slots[i] = ids[i] or "" + if slots[i] and slots[i] ~= "" then + return i + end end - return slots, names + return desired or 1 +end + +local function buildHotbarFromState() + local slots = table.create(HOTBAR_SIZE) + local names = {} + + for i = 1, HOTBAR_SIZE do + local info = ClientState:GetSlotInfo(i) + if info then + slots[i] = tostring(info.id) + names[slots[i]] = info.name or slots[i] + else + slots[i] = "" + end + end + + local selected = resolveSelectedSlot(slots, ClientState:GetSelectedSlot()) + return slots, names, selected end local function ensurePreviewRig(part: Instance) @@ -131,40 +131,43 @@ end local Hotbar = Roact.Component:extend("Hotbar") function Hotbar:init() + local slots, names, selected = buildHotbarFromState() self.state = { - slots = nil, - names = nil, - selected = 1, + slots = slots, + names = names, + selected = selected, } - 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() + self._syncFromClientState = function() + local nextSlots, nextNames, nextSelected = buildHotbarFromState() + nextSelected = resolveSelectedSlot(nextSlots, nextSelected or self.state.selected) self:setState({ slots = nextSlots, names = nextNames, + selected = nextSelected, }) + local id = nextSlots[nextSelected] or "" + local name = "" + if id ~= "" then + name = nextNames[id] or id + end + PlacementState:SetSelected(id, name) end self._setSelected = function(slot: number) if slot < 1 or slot > HOTBAR_SIZE then return end + local info = ClientState:GetSlotInfo(slot) + if not info then + return + end + ClientState:SetSelectedSlot(slot) self:setState({ selected = slot, }) - local id = self.state.slots and self.state.slots[slot] or "" - local name = "" - if id ~= "" and self.state.names then - name = self.state.names[id] or id - end + local id = tostring(info.id) + local name = info.name or id Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", id, "name", name) PlacementState:SetSelected(id, name) end @@ -258,11 +261,11 @@ end function Hotbar:didMount() self._connections = { - blocksFolder.ChildAdded:Connect(self._updateSlots), - blocksFolder.ChildRemoved:Connect(self._updateSlots), + ClientState.Changed:Connect(self._syncFromClientState), UIS.InputBegan:Connect(self._handleInput), UIS.InputChanged:Connect(self._handleScroll), } + self._syncFromClientState() self:_refreshViewports() -- initialize selection broadcast local id = self.state.slots and self.state.slots[self.state.selected] or "" diff --git a/ThirdParty/Character-Realism b/ThirdParty/Character-Realism new file mode 160000 index 0000000..14021f7 --- /dev/null +++ b/ThirdParty/Character-Realism @@ -0,0 +1 @@ +Subproject commit 14021f7c4d0c89dd99d5a5af5f64b363c903767c diff --git a/wally.lock b/wally.lock index 28a443c..4e49274 100644 --- a/wally.lock +++ b/wally.lock @@ -7,10 +7,15 @@ name = "evaera/cmdr" version = "1.12.0" dependencies = [] +[[package]] +name = "ivasmigins/replica" +version = "0.1.0" +dependencies = [] + [[package]] name = "ocbwoy3-development-studios/minecraft-roblox" version = "0.1.0" -dependencies = [["cmdr", "evaera/cmdr@1.12.0"], ["roact", "roblox/roact@1.4.4"]] +dependencies = [["cmdr", "evaera/cmdr@1.12.0"], ["replica", "ivasmigins/replica@0.1.0"], ["roact", "roblox/roact@1.4.4"]] [[package]] name = "roblox/roact" diff --git a/wally.toml b/wally.toml index 47b8c51..fdbb37a 100644 --- a/wally.toml +++ b/wally.toml @@ -7,3 +7,4 @@ realm = "shared" [dependencies] cmdr = "evaera/cmdr@1.12.0" roact = "roblox/roact@1.4.4" +replica = "ivasmigins/replica@0.1.0" \ No newline at end of file