Working On A Simple Item Shop [Detecting A Nearby Shop, SurfaceGui Buy Buttons, Server-Sided Cash/Item Transactions]

Hey all! I’ve been working on this little item shop system for a couple days now and finally have something worth showcasing.

The scripts for it are still pretty messy, but y’all can have a look at what I’ve written so far, for those of you that’d like to use it yourselves for something. I’m still planning on adding more to it and improving on it, so any tips or feedback would be appreciated! I’ll also be sure to post updated versions whenever I get a chance to work on it more.

My goal with it is mainly to future proof the system I have now and expand upon it enough to be compatible with new items, more shops, etc.

Here are all the components that go into it:

ShopHandler (ServerScript, ServerScriptService)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local Players = game:GetService("Players")

local PlayerInfoFolder = ServerStorage:FindFirstChild("PlayerInfo")

Players.PlayerAdded:Connect(function(player)
	local playerInfo = Instance.new("Folder")
	playerInfo.Name = player.Name
	playerInfo.Parent = PlayerInfoFolder
	
	local playerCash = Instance.new("NumberValue")
	playerCash.Name = "Money"
	playerCash.Value = 160
	playerCash.Parent = playerInfo
end)

local menuOpenEvent = ReplicatedStorage.Events.ShopMenuOpen
local buyItemEvent = ReplicatedStorage.Events.BuyItem

menuOpenEvent.OnServerEvent:Connect(function(player, merchant)
	print(`Player: {player} sent request to open shop: {merchant}...`)
	local shop = workspace:FindFirstChild(`{merchant}Shop`)
	if not shop then
		error("Shop not found!")
		return
	end
	menuOpenEvent:FireClient(player, shop.DisplayItems)
	print("Success!")
end)

local errLowCash = "NOT ENOUGH MONEY"
local errNoItem = "ITEM NOT FOUND"

buyItemEvent.OnServerEvent:Connect(function(player, merchant, itemName)
	
	print(`Server received purchase request for item: {itemName}`)
	
	local playerInfo = PlayerInfoFolder:FindFirstChild(player.Name)
	local playerGui = player:FindFirstChildOfClass("PlayerGui")
	local shopItem = workspace:FindFirstChild(`{merchant}Shop`):FindFirstChild(`DisplayItems`):FindFirstChild(itemName)
	
	if not shopItem then
		warn(errNoItem)
		return
	end
	
	local plrCash = playerInfo:FindFirstChild("Money")
	
	if plrCash.Value < shopItem.Price.Value then
		warn(errLowCash)
		buyItemEvent:FireClient(player, merchant, itemName, plrCash.Value, errLowCash)
		return
	end
	
	plrCash.Value -= shopItem.Price.Value
	ServerStorage.ShopItems[merchant][itemName]:Clone().Parent = player.Backpack
	
	buyItemEvent:FireClient(player, merchant, itemName, plrCash.Value)
	
	print("Purchase success!")
	
end)
ShopClient (Client-Side ModuleScript, ReplicatedStorage)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StarterGui = game:GetService("StarterGui")

local self; self = setmetatable({}, {__index = self})

function self.Awake(playerChar)
	self.character = playerChar
	self.playerRoot = playerChar:WaitForChild("HumanoidRootPart")
end

function self.Sleep()
	self.character = nil
	self.playerRoot = nil
	self.currentMerchant = nil
end

function self.characterExists() : boolean
	return self.character and self.playerRoot
end

function self.playerTooFar(p1 : BasePart, p2 : BasePart) : boolean
	return (p1.CFrame.Position - p2.CFrame.Position).Magnitude > 20
end

--[[function self:ShowHideCharacter()
	return function()
		for _,part in pairs(self.character:GetChildren()) do
			if not part:IsA("BasePart") or part.Name == "HumanoidRootPart" then continue end
			part.Transparency = part.Transparency == 0 and .8 or 0
		end
	end
end]]
	
function self.RequestOpenShopGui(inputObject, processed : boolean | number)
	if not self.currentMerchant or not self.characterExists then return end
	if type(processed) == "boolean" and inputObject.KeyCode ~= Enum.KeyCode.F then return end
	ReplicatedStorage.Events.ShopMenuOpen:FireServer(self.currentMerchant)
end

function self.onBuyFailure(playerGui, cashAmount, merchantName, itemName, plrCash, errorMessage)
	if merchantName ~= self.currentMerchant then return end

	cashAmount.Value = plrCash

	for _,surfaceGui : SurfaceGui in pairs(playerGui[merchantName]:GetChildren()) do
		if not surfaceGui:FindFirstChild("ItemName") or surfaceGui:FindFirstChild("ItemName").Value ~= itemName then continue end

		local buyButton = surfaceGui:FindFirstChildOfClass("TextButton")
		buyButton.Text = errorMessage

		local i = 1
		repeat
			buyButton.BackgroundColor3 = Color3.fromRGB(255, 90, 40)
			task.wait(.2)
			buyButton.BackgroundColor3 = Color3.fromRGB(180, 30, 10)
			task.wait(.2)
			i += 1
		until i == 5

		buyButton.Text = `{itemName}: ${workspace:FindFirstChild(`{merchantName}Shop`).DisplayItems[itemName].Price.Value}`
		buyButton.BackgroundColor3 = Color3.fromRGB(255, 40, 0)
	end

end

function self.onShopNearby(merchant, merchantRoot, shopPrompt, playerGui)
	if self.currentMerchant == merchant.Name then return end
	print("Player is in shop vicinity!")
	self.currentMerchant = merchant.Name
	shopPrompt.Adornee = merchantRoot
	shopPrompt.Parent = playerGui
	StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.Chat, true)
end
	
return self
ShopScript (LocalScript, StarterPlayerScripts)
local UserInputSVC = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local RunSVC = game:GetService("RunService")
local CollectionSVC = game:GetService("CollectionService")
local TweenSVC = game:GetService("TweenService")
local StarterGui = game:GetService("StarterGui")

local player = Players.LocalPlayer
local ShopClient = require(ReplicatedStorage.Modules.ShopClient)

player.CharacterAdded:Connect(ShopClient.Awake)

player.CharacterRemoving:Connect(ShopClient.Sleep)

local playerGui = player:FindFirstChildOfClass("PlayerGui")
local exitShopGui = playerGui:WaitForChild("ExitShop")
local exitShopButton = exitShopGui:WaitForChild("ExitButton")
local cashGui = playerGui:WaitForChild("CashGui")
local cashDisplay = cashGui:WaitForChild("Display")
local cashAmount = cashGui:WaitForChild("Amount")
cashDisplay.Text = `${cashAmount.Value}`

local function getItemFromGui(itemName)
	return workspace:FindFirstChild(`{ShopClient.currentMerchant}Shop`).DisplayItems[itemName]
end

cashAmount.Changed:Connect(function()
	cashDisplay.Text = `${cashAmount.Value}`
	
	if not ShopClient.currentMerchant then return end
	
	local shopItem
	
	for _,buyButton in pairs(playerGui[ShopClient.currentMerchant]:GetDescendants()) do
		if not buyButton:IsA("TextButton") or buyButton.Name ~= "BuyButton" then continue end
		
		shopItem = getItemFromGui(buyButton.Parent.ItemName.Value)
		
		if not shopItem then continue end
		
		if cashAmount.Value < shopItem.Price.Value then
			buyButton.BackgroundColor3 = Color3.fromRGB(255, 40, 0)
		else
			buyButton.BackgroundColor3 = Color3.fromRGB(181, 255, 125)
		end
	end
	
	if not shopItem then
		warn("Item does not exist on current client!")
	end
	
end)

local eventsFolder = ReplicatedStorage.Events
local shopGuiFolder = ReplicatedStorage.ShopGui
local shopPrompt = shopGuiFolder.ShopPrompt
local promptButton = shopPrompt:FindFirstChildOfClass("TextButton")

for _,shopFolder in ipairs(shopGuiFolder:GetChildren()) do
	if not shopFolder:IsA("Folder") then continue end
	
	for __, itemGui in ipairs(shopFolder:GetChildren()) do
		itemGui:FindFirstChild("BuyButton").Activated:Connect(function()
			print(`Sending purchase to server for item: {itemGui.ItemName.Value}`)
			eventsFolder.BuyItem:FireServer(ShopClient.currentMerchant, itemGui.ItemName.Value)
		end)
	end
	
end

local function onBuy(...)
	local merchantName, itemName, plrCash, errorMessage = ...
	if errorMessage then
		warn(`Failed to buy item: {itemName} from merchant: {merchantName} | Reason: {errorMessage}`)
		ShopClient.onBuyFailure(playerGui, cashAmount, ...)
		return
	end
	
	cashAmount.Value = plrCash
	print("Purchase success!")
end

eventsFolder.BuyItem.OnClientEvent:Connect(onBuy)

promptButton.Activated:Connect(ShopClient.RequestOpenShopGui)

UserInputSVC.InputBegan:Connect(ShopClient.RequestOpenShopGui)

local camera = workspace.CurrentCamera
local camEnterTweenInfo = TweenInfo.new(1.5, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut)
local camExitTweenInfo = TweenInfo.new(.8, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut)
local lastCameraCoord

local function onExitShop()
	print("Player exiting shop.")
	
	exitShopGui.Enabled = false
	shopPrompt.Parent = shopGuiFolder
	RunSVC:UnbindFromRenderStep("SpinShopParts")

	-- RunSVC.RenderStepped:Once(ShopClient.ShowHideCharacter)

	for _,shopItem in pairs(workspace:FindFirstChild(`{ShopClient.currentMerchant}Shop`).DisplayItems:GetChildren()) do
		shopItem.DisplayPart.Transparency = 1
	end
	
	ShopClient.currentMerchant = nil

	if not ShopClient.shopGui then return end

	ShopClient.shopGui.Parent = shopGuiFolder
	ShopClient.shopGui = nil
	
	local camExitTween = TweenSVC:Create(camera, camExitTweenInfo, {CFrame = lastCameraCoord})
	
	camExitTween.Completed:Connect(function()
		camExitTween:Destroy()
	end)
	
	camExitTween:Play()
	task.wait(.4)
	
	lastCameraCoord = nil
	
	camera.CameraType = Enum.CameraType.Custom
end

exitShopButton.Activated:Connect(onExitShop)

local function OnShopOpenPassCheck(itemFolder)
	if not itemFolder then
		warn("Shop open failed!")
		return
	end
	
	print("Shop open success!")
	
	shopPrompt.Parent = shopGuiFolder
	ShopClient.shopGui = ReplicatedStorage.ShopGui[ShopClient.currentMerchant]
	
	-- RunSVC.RenderStepped:Once(ShopClient.ShowHideCharacter)
	StarterGui:SetCoreGuiEnabled(Enum.CoreGuiType.Chat, false)
	camera.CameraType = Enum.CameraType.Scriptable
	
	local camTween = TweenSVC:Create(camera, camEnterTweenInfo, {
		CFrame = workspace:FindFirstChild(`{ShopClient.currentMerchant}Shop`):FindFirstChild("CamFocus").CFrame;
	})
	
	lastCameraCoord = camera.CFrame
	
	camTween.Completed:Connect(function()
		ShopClient.shopGui.Parent = playerGui
		
		for _,shopItem in ipairs(itemFolder:GetChildren()) do
			ShopClient.shopGui[`Item{shopItem.ShelfSpot.Value}`].BuyButton.Text = `{shopItem.Name}: ${shopItem.Price.Value}`
			shopItem:FindFirstChild("DisplayPart").Transparency = 0
		end

		RunSVC:BindToRenderStep("SpinShopParts", 1, function()
			for _,shopItem in ipairs(itemFolder:GetChildren()) do
				shopItem.PrimaryPart.CFrame *= CFrame.Angles(0, math.rad(-1), 0)
			end
		end)
		
		camTween:Destroy()
	end)
	
	camTween:Play()
	
	exitShopGui.Enabled = true
	
end

eventsFolder.ShopMenuOpen.OnClientEvent:Connect(OnShopOpenPassCheck)

local function DetectNearbyShop()
	if not ShopClient.characterExists() then
		if shopPrompt.Parent == shopGuiFolder then return end
		shopPrompt.Parent = shopGuiFolder
		return
	end
	
	for _,merchant in pairs(CollectionSVC:GetTagged("ShopNPC")) do
		local merchantRoot = merchant:FindFirstChild("HumanoidRootPart")
		
		if not ShopClient.playerTooFar(ShopClient.playerRoot, merchantRoot) then
			ShopClient.onShopNearby(merchant, merchantRoot, shopPrompt, playerGui)
			return
		end
		
		if ShopClient.currentMerchant ~= merchant.Name then continue end
		
		onExitShop()
		
		continue
	end
	
end

RunSVC.RenderStepped:Connect(DetectNearbyShop)

Here’s the layout for everything in ReplicatedStorage:
image

The layout of the shop in workspace:
image

And here’s everything for ServerStorage and ServerScriptService:
image
(the ShopNPCModule is just for teleporting the merchant NPC back into place in case the weld breaks for some reason lol)

8 Likes