core: fix hotbar

This commit is contained in:
2026-01-08 23:52:32 +02:00
parent 2c41f40151
commit 2a0dd51659
8 changed files with 429 additions and 53 deletions

3
.gitignore vendored
View File

@@ -1 +1,2 @@
Packages/ Packages/
ServerPackages/

View File

@@ -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

View File

@@ -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

View File

@@ -2,15 +2,39 @@
--!optimize 2 --!optimize 2
local ReplicatedStorage = game:GetService("ReplicatedStorage") local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local ClientStateService = require(script.Parent.ClientState)
local Shared = ReplicatedStorage:WaitForChild("Shared") local Shared = ReplicatedStorage:WaitForChild("Shared")
local ModsFolder = ReplicatedStorage:WaitForChild("Mods") 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 Util = require(Shared.Util)
local TG = require("./ServerChunkManager/TerrainGen") local TG = require(script.TerrainGen)
local Players = game:GetService("Players") 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 do
local workspaceModFolder = game:GetService("Workspace"):WaitForChild("mods") local workspaceModFolder = game:GetService("Workspace"):WaitForChild("mods")
@@ -22,6 +46,8 @@ end
local ML = require(Shared.ModLoader) local ML = require(Shared.ModLoader)
ML.loadModsS() ML.loadModsS()
syncBlocksToServerStorage()
ClientStateService:Init()
do do
local bv = Instance.new("BoolValue") local bv = Instance.new("BoolValue")
@@ -67,7 +93,7 @@ local tickRemote = ReplicatedStorage.Tick
local remotes = ReplicatedStorage:WaitForChild("Remotes") local remotes = ReplicatedStorage:WaitForChild("Remotes")
local placeRemote = remotes:WaitForChild("PlaceBlock") local placeRemote = remotes:WaitForChild("PlaceBlock")
local breakRemote = remotes:WaitForChild("BreakBlock") local breakRemote = remotes:WaitForChild("BreakBlock")
local blocksFolder = ReplicatedStorage:WaitForChild("Blocks") local blocksFolder = BlocksFolderSS
local function propogate(a, cx, cy, cz, x, y, z, bd) local function propogate(a, cx, cy, cz, x, y, z, bd)
task.synchronize() task.synchronize()
tickRemote:FireAllClients(a, cx, cy, cz, x, y, z, bd) 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 end
local MAX_REACH = 512 local MAX_REACH = 512
local blockIdMap = {}
local function rebuildBlockIdMap() rebuildBlockIdMap = function()
table.clear(blockIdMap) table.clear(blockIdMap)
for _, block in ipairs(blocksFolder:GetChildren()) do for _, block in ipairs(blocksFolder:GetChildren()) do
local id = block:GetAttribute("n") local id = block:GetAttribute("n")
@@ -113,6 +138,17 @@ local function resolveBlockId(blockId: any): string | number | nil
return blockIdMap[blockId] return blockIdMap[blockId]
end 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) local function getServerChunk(cx: number, cy: number, cz: number)
task.desynchronize() task.desynchronize()
local chunk = TG:GetChunk(cx, cy, cz) 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 if not resolvedId then
return reject("invalid id") return reject("invalid id")
end 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 local blockPos = Util.ChunkPosToCFrame(Vector3.new(cx, cy, cz), Vector3.new(x, y, z)).Position
if isBlockInsidePlayer(blockPos) then if isBlockInsidePlayer(blockPos) then

View File

@@ -18,8 +18,7 @@ local PM = require(ReplicatedStorage.Shared.PlacementManager)
local BlockManager = require(ReplicatedStorage.Shared.ChunkManager.BlockManager) local BlockManager = require(ReplicatedStorage.Shared.ChunkManager.BlockManager)
local PlacementState = require(ReplicatedStorage.Shared.PlacementState) local PlacementState = require(ReplicatedStorage.Shared.PlacementState)
local Util = require(ReplicatedStorage.Shared.Util) local Util = require(ReplicatedStorage.Shared.Util)
local ClientState = require(ReplicatedStorage.Shared.ClientState)
local blocksFolder = ReplicatedStorage:WaitForChild("Blocks")
local HOTBAR_SIZE = 10 local HOTBAR_SIZE = 10
@@ -53,33 +52,34 @@ local function isTextInputFocused(): boolean
return config ~= nil and config.IsFocused return config ~= nil and config.IsFocused
end end
local function buildHotbarIds(): {string} local function resolveSelectedSlot(slots, desired)
local ids = {} if desired and desired >= 1 and desired <= HOTBAR_SIZE and slots[desired] ~= "" then
local names = {} return desired
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
end 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 for i = 1, HOTBAR_SIZE do
slots[i] = ids[i] or "" if slots[i] and slots[i] ~= "" then
return i
end
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 end
local function ensurePreviewRig(part: Instance) local function ensurePreviewRig(part: Instance)
@@ -131,40 +131,43 @@ end
local Hotbar = Roact.Component:extend("Hotbar") local Hotbar = Roact.Component:extend("Hotbar")
function Hotbar:init() function Hotbar:init()
local slots, names, selected = buildHotbarFromState()
self.state = { self.state = {
slots = nil, slots = slots,
names = nil, names = names,
selected = 1, 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() self._syncFromClientState = function()
local nextSlots, nextNames = buildHotbarIds() local nextSlots, nextNames, nextSelected = buildHotbarFromState()
nextSelected = resolveSelectedSlot(nextSlots, nextSelected or self.state.selected)
self:setState({ self:setState({
slots = nextSlots, slots = nextSlots,
names = nextNames, 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 end
self._setSelected = function(slot: number) self._setSelected = function(slot: number)
if slot < 1 or slot > HOTBAR_SIZE then if slot < 1 or slot > HOTBAR_SIZE then
return return
end end
local info = ClientState:GetSlotInfo(slot)
if not info then
return
end
ClientState:SetSelectedSlot(slot)
self:setState({ self:setState({
selected = slot, selected = slot,
}) })
local id = self.state.slots and self.state.slots[slot] or "" local id = tostring(info.id)
local name = "" local name = info.name or id
if id ~= "" and self.state.names then
name = self.state.names[id] or id
end
Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", id, "name", name) Util.StudioLog("[PLACE][CLIENT][SELECT]", "slot", slot, "id", id, "name", name)
PlacementState:SetSelected(id, name) PlacementState:SetSelected(id, name)
end end
@@ -258,11 +261,11 @@ end
function Hotbar:didMount() function Hotbar:didMount()
self._connections = { self._connections = {
blocksFolder.ChildAdded:Connect(self._updateSlots), ClientState.Changed:Connect(self._syncFromClientState),
blocksFolder.ChildRemoved:Connect(self._updateSlots),
UIS.InputBegan:Connect(self._handleInput), UIS.InputBegan:Connect(self._handleInput),
UIS.InputChanged:Connect(self._handleScroll), UIS.InputChanged:Connect(self._handleScroll),
} }
self._syncFromClientState()
self:_refreshViewports() self:_refreshViewports()
-- initialize selection broadcast -- initialize selection broadcast
local id = self.state.slots and self.state.slots[self.state.selected] or "" local id = self.state.slots and self.state.slots[self.state.selected] or ""

1
ThirdParty/Character-Realism vendored Submodule

View File

@@ -7,10 +7,15 @@ name = "evaera/cmdr"
version = "1.12.0" version = "1.12.0"
dependencies = [] dependencies = []
[[package]]
name = "ivasmigins/replica"
version = "0.1.0"
dependencies = []
[[package]] [[package]]
name = "ocbwoy3-development-studios/minecraft-roblox" name = "ocbwoy3-development-studios/minecraft-roblox"
version = "0.1.0" 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]] [[package]]
name = "roblox/roact" name = "roblox/roact"

View File

@@ -7,3 +7,4 @@ realm = "shared"
[dependencies] [dependencies]
cmdr = "evaera/cmdr@1.12.0" cmdr = "evaera/cmdr@1.12.0"
roact = "roblox/roact@1.4.4" roact = "roblox/roact@1.4.4"
replica = "ivasmigins/replica@0.1.0"