···11+--!native
22+--!optimize 2
33+44+local RunService = game:GetService("RunService")
55+66+if RunService:IsServer() then
77+ error("ClientState can only be required on the client")
88+end
99+1010+local Players = game:GetService("Players")
1111+local ReplicatedStorage = game:GetService("ReplicatedStorage")
1212+1313+local Replica = require(ReplicatedStorage.Packages.replica)
1414+1515+local ClientState = {}
1616+1717+local HOTBAR_SIZE = 10
1818+1919+local localPlayer = Players.LocalPlayer
2020+local replicaForPlayer = nil
2121+local changed = Instance.new("BindableEvent")
2222+2323+local function fireChanged()
2424+ changed:Fire()
2525+end
2626+2727+local function onReplicaNew(replica)
2828+ local tags = replica.Tags or {}
2929+ if tags.UserId ~= localPlayer.UserId and tags.Player ~= localPlayer then
3030+ return
3131+ end
3232+3333+ replicaForPlayer = replica
3434+ replica:OnChange(fireChanged)
3535+ fireChanged()
3636+end
3737+3838+Replica.OnNew("ClientState", onReplicaNew)
3939+Replica.RequestData()
4040+4141+function ClientState:IsReady(): boolean
4242+ return replicaForPlayer ~= nil
4343+end
4444+4545+function ClientState:GetReplica()
4646+ return replicaForPlayer
4747+end
4848+4949+function ClientState:GetSelectedSlot(): number?
5050+ if not replicaForPlayer then
5151+ return nil
5252+ end
5353+ return replicaForPlayer.Data.selectedSlot
5454+end
5555+5656+local function getInventory()
5757+ return replicaForPlayer and replicaForPlayer.Data.inventory or nil
5858+end
5959+6060+function ClientState:GetItemInfo(blockId: any)
6161+ if not replicaForPlayer or not blockId then
6262+ return nil
6363+ end
6464+ local inv = getInventory()
6565+ local entry = inv and inv[tostring(blockId)]
6666+ if not entry then
6767+ return nil
6868+ end
6969+7070+ return {
7171+ id = tostring(blockId),
7272+ name = entry.name or tostring(blockId),
7373+ count = entry.count,
7474+ }
7575+end
7676+7777+function ClientState:GetHotbarSlots(): {string}
7878+ if not replicaForPlayer then
7979+ local slots = table.create(HOTBAR_SIZE)
8080+ for i = 1, HOTBAR_SIZE do
8181+ slots[i] = ""
8282+ end
8383+ return slots
8484+ end
8585+8686+ return replicaForPlayer.Data.hotbar or {}
8787+end
8888+8989+function ClientState:GetSlotInfo(slot: number)
9090+ if not replicaForPlayer then
9191+ return nil
9292+ end
9393+ local hotbar = replicaForPlayer.Data.hotbar
9494+ if not hotbar then
9595+ return nil
9696+ end
9797+ local id = hotbar[slot]
9898+ if not id then
9999+ return nil
100100+ end
101101+ return ClientState:GetItemInfo(id)
102102+end
103103+104104+function ClientState:GetSelectedBlock()
105105+ if not replicaForPlayer then
106106+ return nil
107107+ end
108108+ local slot = ClientState:GetSelectedSlot()
109109+ if not slot then
110110+ return nil
111111+ end
112112+ return ClientState:GetSlotInfo(slot)
113113+end
114114+115115+function ClientState:SetSelectedSlot(slot: number)
116116+ if not replicaForPlayer then
117117+ return
118118+ end
119119+ local hotbar = replicaForPlayer.Data.hotbar
120120+ if not hotbar or not hotbar[slot] then
121121+ return
122122+ end
123123+ replicaForPlayer:FireServer("SelectHotbarSlot", slot)
124124+end
125125+126126+ClientState.Changed = changed.Event
127127+128128+return ClientState
+198
ServerScriptService/Actor/ClientState.lua
···11+--!native
22+--!optimize 2
33+44+local Players = game:GetService("Players")
55+local ReplicatedStorage = game:GetService("ReplicatedStorage")
66+77+local Replica = require(ReplicatedStorage.Packages.replica)
88+99+local ClientStateService = {}
1010+1111+local HOTBAR_SIZE = 10
1212+1313+local token = Replica.Token("ClientState")
1414+1515+local blockCatalog = {}
1616+local playerReplicas = {} :: {[Player]: any}
1717+local blocksFolder: Folder? = nil
1818+local readyConnections = {} :: {[Player]: RBXScriptConnection}
1919+2020+local function sortBlocks()
2121+ table.sort(blockCatalog, function(a, b)
2222+ local na = tonumber(a.id)
2323+ local nb = tonumber(b.id)
2424+ if na and nb then
2525+ return na < nb
2626+ end
2727+ if na then
2828+ return true
2929+ end
3030+ if nb then
3131+ return false
3232+ end
3333+ return a.id < b.id
3434+ end)
3535+end
3636+3737+local function rebuildBlockCatalog()
3838+ table.clear(blockCatalog)
3939+ if not blocksFolder then
4040+ return
4141+ end
4242+4343+ for _, block in ipairs(blocksFolder:GetChildren()) do
4444+ local id = block:GetAttribute("n")
4545+ if id ~= nil then
4646+ table.insert(blockCatalog, {
4747+ id = tostring(id),
4848+ name = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name,
4949+ })
5050+ end
5151+ end
5252+ sortBlocks()
5353+end
5454+5555+local function makeBaseState()
5656+ local inventory = {}
5757+ local hotbar = {}
5858+5959+ for _, entry in ipairs(blockCatalog) do
6060+ inventory[entry.id] = {
6161+ name = entry.name,
6262+ count = 999999,
6363+ }
6464+ if #hotbar < HOTBAR_SIZE then
6565+ table.insert(hotbar, entry.id)
6666+ end
6767+ end
6868+6969+ return {
7070+ inventory = inventory,
7171+ hotbar = hotbar,
7272+ selectedSlot = #hotbar > 0 and 1 or 0,
7373+ }
7474+end
7575+7676+local function sanitizeSelection(hotbar, selectedSlot)
7777+ if type(selectedSlot) ~= "number" then
7878+ return (#hotbar > 0) and 1 or 0
7979+ end
8080+ if selectedSlot < 1 or selectedSlot > HOTBAR_SIZE then
8181+ return (#hotbar > 0) and 1 or 0
8282+ end
8383+ if not hotbar[selectedSlot] then
8484+ return (#hotbar > 0) and 1 or 0
8585+ end
8686+ return selectedSlot
8787+end
8888+8989+local function refreshReplica(replica)
9090+ local state = makeBaseState()
9191+ replica:Set({"inventory"}, state.inventory)
9292+ replica:Set({"hotbar"}, state.hotbar)
9393+ replica:Set({"selectedSlot"}, sanitizeSelection(state.hotbar, replica.Data.selectedSlot))
9494+end
9595+9696+function ClientStateService:SetBlocksFolder(folder: Folder?)
9797+ blocksFolder = folder
9898+ rebuildBlockCatalog()
9999+ for _, replica in pairs(playerReplicas) do
100100+ refreshReplica(replica)
101101+ end
102102+end
103103+104104+function ClientStateService:GetReplica(player: Player)
105105+ return playerReplicas[player]
106106+end
107107+108108+function ClientStateService:GetSelectedBlockId(player: Player)
109109+ local replica = playerReplicas[player]
110110+ if not replica then
111111+ return nil
112112+ end
113113+ local data = replica.Data
114114+ local hotbar = data.hotbar or {}
115115+ local selectedSlot = sanitizeSelection(hotbar, data.selectedSlot)
116116+ return hotbar[selectedSlot]
117117+end
118118+119119+function ClientStateService:HasInInventory(player: Player, blockId: any): boolean
120120+ local replica = playerReplicas[player]
121121+ if not replica or not blockId then
122122+ return false
123123+ end
124124+ local inv = replica.Data.inventory
125125+ return inv and inv[tostring(blockId)] ~= nil or false
126126+end
127127+128128+local function handleReplicaEvents(player: Player, replica)
129129+ replica.OnServerEvent:Connect(function(plr, action, payload)
130130+ if plr ~= player then
131131+ return
132132+ end
133133+134134+ if action == "SelectHotbarSlot" then
135135+ local slot = tonumber(payload)
136136+ local hotbar = replica.Data.hotbar
137137+ if not hotbar then
138138+ return
139139+ end
140140+ if slot and slot >= 1 and slot <= HOTBAR_SIZE and hotbar[slot] then
141141+ replica:Set({"selectedSlot"}, slot)
142142+ end
143143+ end
144144+ end)
145145+end
146146+147147+local function onPlayerAdded(player: Player)
148148+ local replica = Replica.New({
149149+ Token = token,
150150+ Tags = {
151151+ UserId = player.UserId,
152152+ Player = player,
153153+ },
154154+ Data = makeBaseState(),
155155+ })
156156+157157+ if Replica.ReadyPlayers[player] then
158158+ replica:Subscribe(player)
159159+ else
160160+ readyConnections[player] = Replica.NewReadyPlayer:Connect(function(newPlayer)
161161+ if newPlayer ~= player then
162162+ return
163163+ end
164164+ if readyConnections[player] then
165165+ readyConnections[player]:Disconnect()
166166+ readyConnections[player] = nil
167167+ end
168168+ replica:Subscribe(player)
169169+ end)
170170+ end
171171+172172+ handleReplicaEvents(player, replica)
173173+ playerReplicas[player] = replica
174174+end
175175+176176+local function onPlayerRemoving(player: Player)
177177+ local replica = playerReplicas[player]
178178+ if replica then
179179+ replica:Destroy()
180180+ playerReplicas[player] = nil
181181+ end
182182+ if readyConnections[player] then
183183+ readyConnections[player]:Disconnect()
184184+ readyConnections[player] = nil
185185+ end
186186+end
187187+188188+function ClientStateService:Init()
189189+ rebuildBlockCatalog()
190190+191191+ for _, player in ipairs(Players:GetPlayers()) do
192192+ onPlayerAdded(player)
193193+ end
194194+ Players.PlayerAdded:Connect(onPlayerAdded)
195195+ Players.PlayerRemoving:Connect(onPlayerRemoving)
196196+end
197197+198198+return ClientStateService
···22--!optimize 2
3344local ReplicatedStorage = game:GetService("ReplicatedStorage")
55-55+local ServerStorage = game:GetService("ServerStorage")
66+local ClientStateService = require(script.Parent.ClientState)
6778local Shared = ReplicatedStorage:WaitForChild("Shared")
89local ModsFolder = ReplicatedStorage:WaitForChild("Mods")
1010+local BlocksFolderRS = ReplicatedStorage:FindFirstChild("Blocks") or Instance.new("Folder")
1111+BlocksFolderRS.Name = "Blocks"
1212+BlocksFolderRS.Parent = ReplicatedStorage
1313+local BlocksFolderSS = ServerStorage:FindFirstChild("Blocks") or Instance.new("Folder")
1414+BlocksFolderSS.Name = "Blocks"
1515+BlocksFolderSS.Parent = ServerStorage
9161017local Util = require(Shared.Util)
1111-local TG = require("./ServerChunkManager/TerrainGen")
1818+local TG = require(script.TerrainGen)
1219local Players = game:GetService("Players")
13202121+local blockIdMap = {}
2222+local rebuildBlockIdMap
2323+2424+local function syncBlocksToServerStorage()
2525+ BlocksFolderSS:ClearAllChildren()
2626+ for _, child in ipairs(BlocksFolderRS:GetChildren()) do
2727+ child:Clone().Parent = BlocksFolderSS
2828+ end
2929+ ClientStateService:SetBlocksFolder(BlocksFolderSS)
3030+ if rebuildBlockIdMap then
3131+ rebuildBlockIdMap()
3232+ end
3333+end
3434+3535+BlocksFolderRS.ChildAdded:Connect(syncBlocksToServerStorage)
3636+BlocksFolderRS.ChildRemoved:Connect(syncBlocksToServerStorage)
3737+1438do
1539 local workspaceModFolder = game:GetService("Workspace"):WaitForChild("mods")
1640···22462347local ML = require(Shared.ModLoader)
2448ML.loadModsS()
4949+syncBlocksToServerStorage()
5050+ClientStateService:Init()
25512652do
2753 local bv = Instance.new("BoolValue")
···6793local remotes = ReplicatedStorage:WaitForChild("Remotes")
6894local placeRemote = remotes:WaitForChild("PlaceBlock")
6995local breakRemote = remotes:WaitForChild("BreakBlock")
7070-local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")
9696+local blocksFolder = BlocksFolderSS
7197local function propogate(a, cx, cy, cz, x, y, z, bd)
7298 task.synchronize()
7399 tickRemote:FireAllClients(a, cx, cy, cz, x, y, z, bd)
···75101end
7610277103local MAX_REACH = 512
7878-local blockIdMap = {}
791048080-local function rebuildBlockIdMap()
105105+rebuildBlockIdMap = function()
81106 table.clear(blockIdMap)
82107 for _, block in ipairs(blocksFolder:GetChildren()) do
83108 local id = block:GetAttribute("n")
···113138 return blockIdMap[blockId]
114139end
115140141141+local function playerCanUseBlock(player: Player, resolvedId: any): boolean
142142+ if not ClientStateService:HasInInventory(player, resolvedId) then
143143+ return false
144144+ end
145145+ local selected = ClientStateService:GetSelectedBlockId(player)
146146+ if not selected then
147147+ return false
148148+ end
149149+ return tostring(selected) == tostring(resolvedId)
150150+end
151151+116152local function getServerChunk(cx: number, cy: number, cz: number)
117153 task.desynchronize()
118154 local chunk = TG:GetChunk(cx, cy, cz)
···175211 local resolvedId = resolveBlockId(blockId)
176212 if not resolvedId then
177213 return reject("invalid id")
214214+ end
215215+ if not playerCanUseBlock(player, resolvedId) then
216216+ return reject("not in inventory/hotbar")
178217 end
179218180219 local blockPos = Util.ChunkPosToCFrame(Vector3.new(cx, cy, cz), Vector3.new(x, y, z)).Position
+47-44
StarterGui/Hotbar/LocalScript.client.lua
···1818local BlockManager = require(ReplicatedStorage.Shared.ChunkManager.BlockManager)
1919local PlacementState = require(ReplicatedStorage.Shared.PlacementState)
2020local Util = require(ReplicatedStorage.Shared.Util)
2121-2222-local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")
2121+local ClientState = require(ReplicatedStorage.Shared.ClientState)
23222423local HOTBAR_SIZE = 10
2524···5352 return config ~= nil and config.IsFocused
5453end
55545656-local function buildHotbarIds(): {string}
5757- local ids = {}
5858- local names = {}
5959- for _, block in ipairs(blocksFolder:GetChildren()) do
6060- local id = block:GetAttribute("n")
6161- if id ~= nil then
6262- local n = tonumber(id)
6363- if n and n > 0 then
6464- local idStr = tostring(n)
6565- table.insert(ids, idStr)
6666- names[idStr] = block:GetAttribute("displayName") or block:GetAttribute("dn") or block.Name
6767- end
5555+local function resolveSelectedSlot(slots, desired)
5656+ if desired and desired >= 1 and desired <= HOTBAR_SIZE and slots[desired] ~= "" then
5757+ return desired
5858+ end
5959+ for i = 1, HOTBAR_SIZE do
6060+ if slots[i] and slots[i] ~= "" then
6161+ return i
6862 end
6963 end
7070- table.sort(ids, function(a, b)
7171- local na = tonumber(a)
7272- local nb = tonumber(b)
7373- if na and nb then
7474- return na < nb
7575- end
7676- return a < b
7777- end)
6464+ return desired or 1
6565+end
6666+6767+local function buildHotbarFromState()
7868 local slots = table.create(HOTBAR_SIZE)
6969+ local names = {}
7070+7971 for i = 1, HOTBAR_SIZE do
8080- slots[i] = ids[i] or ""
7272+ local info = ClientState:GetSlotInfo(i)
7373+ if info then
7474+ slots[i] = tostring(info.id)
7575+ names[slots[i]] = info.name or slots[i]
7676+ else
7777+ slots[i] = ""
7878+ end
8179 end
8282- return slots, names
8080+8181+ local selected = resolveSelectedSlot(slots, ClientState:GetSelectedSlot())
8282+ return slots, names, selected
8383end
84848585local function ensurePreviewRig(part: Instance)
···131131local Hotbar = Roact.Component:extend("Hotbar")
132132133133function Hotbar:init()
134134+ local slots, names, selected = buildHotbarFromState()
134135 self.state = {
135135- slots = nil,
136136- names = nil,
137137- selected = 1,
136136+ slots = slots,
137137+ names = names,
138138+ selected = selected,
138139 }
139139- local slots, names = buildHotbarIds()
140140- self.state.slots = slots
141141- self.state.names = names
142142- local initialId = slots and slots[1] or ""
143143- if initialId and initialId ~= "" then
144144- local initialName = names and (names[initialId] or initialId) or initialId
145145- PlacementState:SetSelected(initialId, initialName)
146146- end
147140148148- self._updateSlots = function()
149149- local nextSlots, nextNames = buildHotbarIds()
141141+ self._syncFromClientState = function()
142142+ local nextSlots, nextNames, nextSelected = buildHotbarFromState()
143143+ nextSelected = resolveSelectedSlot(nextSlots, nextSelected or self.state.selected)
150144 self:setState({
151145 slots = nextSlots,
152146 names = nextNames,
147147+ selected = nextSelected,
153148 })
149149+ local id = nextSlots[nextSelected] or ""
150150+ local name = ""
151151+ if id ~= "" then
152152+ name = nextNames[id] or id
153153+ end
154154+ PlacementState:SetSelected(id, name)
154155 end
155156156157 self._setSelected = function(slot: number)
157158 if slot < 1 or slot > HOTBAR_SIZE then
158159 return
159160 end
161161+ local info = ClientState:GetSlotInfo(slot)
162162+ if not info then
163163+ return
164164+ end
165165+ ClientState:SetSelectedSlot(slot)
160166 self:setState({
161167 selected = slot,
162168 })
163163- local id = self.state.slots and self.state.slots[slot] or ""
164164- local name = ""
165165- if id ~= "" and self.state.names then
166166- name = self.state.names[id] or id
167167- end
169169+ local id = tostring(info.id)
170170+ local name = info.name or id
168171 Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", id, "name", name)
169172 PlacementState:SetSelected(id, name)
170173 end
···258261259262function Hotbar:didMount()
260263 self._connections = {
261261- blocksFolder.ChildAdded:Connect(self._updateSlots),
262262- blocksFolder.ChildRemoved:Connect(self._updateSlots),
264264+ ClientState.Changed:Connect(self._syncFromClientState),
263265 UIS.InputBegan:Connect(self._handleInput),
264266 UIS.InputChanged:Connect(self._handleScroll),
265267 }
268268+ self._syncFromClientState()
266269 self:_refreshViewports()
267270 -- initialize selection broadcast
268271 local id = self.state.slots and self.state.slots[self.state.selected] or ""