Roblox — Инвентарь и питание персонажа — Character inventory and nutrition

Автор: | 9 марта, 2025
Поделиться...

Итак, в предыдущем уроке сделано уменьшение сытости от времени. Теперь же нужно реализовать как эту сытость восполнять. Само собой что это значит, что нужно что-то съесть. (Ну и много ещё чего попутно…)

Наш текущий инвентарь не позволяет использовать что-либо на прямую из него. Поэтому у нас есть пара вариантов, первый — переделать инвентарь, чтобы можно было в нём выбирать вещь и использовать её. Второй вариант добавить кнопку на экран чтобы автоматически есть пригодное в инвентаре. И второй вариант гораздо проще (кажется) но в игре будет мало применим. И потому нам придётся переделывать сам инвентарь. Пусть это будет нечто примитивное (на вроде экрана крафта), но более применимое.

Но что в данный момент раздражает, так это отсутствие индикатора сытости на экране. Поэтому сделаем простейший вариант HUD для него с локальным скриптом. Для этого у нас всё есть — текущий уровень сытости и его максимальное значение. Поделив одно на другое, мы получим число от 0 до 1. Это именно то, что задаём относительные размеры элементов GUI. Добавим наш индикатор в HUD:

Со скриптом:

-- отображение прогресс бара текущего уровня сытости

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local player = Players.LocalPlayer

local config = require( ReplicatedStorage:WaitForChild("GameConfig") )

local maxSatiety = config.SatietyMaximum	-- максимальная сытость
local timer = config.SatietyInterval / 2	-- половина интервала обновления

while task.wait(timer) do
	local statuses = player:FindFirstChild("Statuses")
	if statuses then	-- скрипт может загрузиться в любой момент времени
		local satiety = statuses:FindFirstChild("Satiety")
		if satiety then
			local procent = math.clamp(satiety.Value / maxSatiety, 0, 1)
			-- только относительное обращение
			script.Parent.Procent.Text = tostring( math.floor(procent * 100) ) .. "%"
			-- размер промежутка от 0 до 1
			script.Parent.Bar.Size = UDim2.new(1, 0, procent,0)
		end
	end
end

Ну вот, от раздражающего фактора избавились. Займёмся вторым — переделкой инвентаря… Итак, с учётом что мы не только для ПК, но и для носимых гаджетов игры делаем, то нам нужна будет кнопка использования, кроме самого списка того, что в нём имеется. Естественно, после того как мы это выберем. Самое простое — просто продублируем GUI крафта и уже в нём будем делать правки. Разве что сразу заменим фоновый цвет у копии, чтобы не путать с оригиналом. Ну и надпись на кнопке поменяем с Craft, на Use. Получим нечто такое:

Переходим к скрипту. Первое — заполнять его мы будем уже из инвентаря, что в игроке хранится, а не из таблицы. Точнее не так, заполнять будем из инвентаря, а вот описание будем брать из таблицы. И первое что мы получим — отсутствие количества на экране, а второе — нам не известно что же мы можем использовать, а что нет. С первым справится легко — просто добавим текстовую метку под называнием, а вот для второго нам придётся обновлять таблицу товаров добавив туда флаг возможности использования. Но у нас появляется очередной вопрос — в какую таблицу добавлять? А может создать отдельную таблицу для этого? Сейчас мы так и сделаем, для съестного создадим отдельную таблицу. Забегая наперёд скажу, что для всего этого (ресурсы, крафт, использование) нужна одна таблица в которой будет всё и сразу.

В данный момент что можем добавить? Можем добавить добычу ягод с кустов/цветочков. Соответственно откорректируем и таблицу добычи изменив лишь один момент:

	Flowers = {
		Axe = {	-- инструмент для добычи
			None = 70,		-- ничего
			Berry = 10,		-- ягода
			Leaves = 20,	-- листья
		}
		-- кирка НЕ подходящий инструмент
	}

И соответственно создадим новую табличку Used. Банально пропишем что и на сколько будет изменятся при использовании:

return {
	Berry = {
		Title = "Ягоды",					-- название
		Desc = "Вполне съедобные ягоды",	-- описание
		Param = "Satiety",					-- изменяемый параметр
		Delta = 10,							-- на указанное число
	},
}

Пора уже переходить к отображению инвентаря. Скрестим старые инвентарь и крафт.

Первичное отличие окна инвентаря от окна крафта, что у нас будет постоянно меняющийся список содержимого инвентаря (ровно как и количество), как следствие нам нужно иметь возможность перерисовать содержимое инвентаря. Второе, у нас будет две таблицы откуда брать информацию из ресурсов и крафта. Третье, только наличие в таблице использования, даст разрешение на отображение кнопки «использовать». Потому и говорил, что нужна будет единая таблица на все ресурсы игры. Но мы же не ищем лёгких пути, изобретая велосипед по пути наименьшего сопротивления?

Потому наш скрипт будет из нескольких частей: отображение актуальной информации, отображение информации о выбранном элементе, кнопка для использование элемента.

-- скрипт инвентаря с использованием

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local used = require( ReplicatedStorage:WaitForChild("Resource"):WaitForChild("Used") )
local craftTable = require( ReplicatedStorage:WaitForChild("Resource"):WaitForChild("Craft") )
local config = require( ReplicatedStorage:WaitForChild("GameConfig") )

local player = Players.LocalPlayer 

screen = script.Parent								-- наш экран

local invent = player:WaitForChild("Inventory")		-- ссылка на инвентарь

-- элементы GUI 
local info = screen:WaitForChild("Info")
local selected = info:WaitForChild("Select")

local recept = screen:WaitForChild("Recipient")
local sample = recept:WaitForChild("Sample"):Clone()
local ui = recept:WaitForChild("UIListLayout"):Clone()

-- изменение выбранного рецепта
local function SelectChange(value)
	local val = selected.Value	-- что именно выбрали

	local flag = false			-- флаг применимости
	local title = ""			-- Название
	local desc = ""				-- описание
	local comp = ""				-- состав

	-- информация по таблице Craft	
	local craft = craftTable[val]
	if craft then
		title = craft.Title
		local res = ""
		for a, b in pairs(craft.Resource) do
			if res ~= "" then	-- добавим перевод строки
				res = res .. "\n"
			end
			res = res .. a  .. " x " .. tostring(b)
		end
		comp = res
	end

	-- информация по таблице Used
	local element = used[val]
	if element then	-- элемент найден
		if element.Param and element.Delta then	-- описано применение
			title = element.Title
			desc = element.Desc
			
			local status = player:FindFirstChild("Statuses")
			if status then
				local param = status:FindFirstChild(element.Param)
				if param then	-- есть что изменять
					flag = true
				else
					warn("Параметр", element.Param, "не найден в игроке")
				end
			else
				warn("Статусы игрока ещё не прогружены")
			end
		end
	end

	-- не найдено информации
	if title == "" then
		title = val
	end

	info.Title.Text = title
	info.Description.Text = desc
	info.Compound.Text = comp

	if flag then
		info.btnUse.Interactable = true
		info.btnUse.TextColor3 = Color3.new(0, 1, 0)
	else
		info.btnUse.Interactable = false
		info.btnUse.TextColor3 = Color3.new(1, 0, 0)
	end
end
selected.Changed:Connect(SelectChange)

-- выбор предмета
function SelectUse()	-- раз сюда попали, значит проверки уже сделаны
	local val = selected.Value	-- что именно выбрали
	
	local element = used[val]
	-- Прибавим в состоянии
	local param = player.Statuses:FindFirstChild(element.Param)
	param.Value = param.Value + element.Delta
	if param.Value > config.SatietyMaximum then	-- не более максимума
		param.Value = config.SatietyMaximum
	end
	
	-- Убавим в количестве
	local inv = player.Inventory:FindFirstChild(val)
	inv.Value = inv.Value - 1
	if inv.Value == 0 then
		inv:Destroy()	-- уничтожим, раз кончилось
	end	
end
info.btnUse.Activated:Connect(SelectUse)

local flag = Instance.new("BoolValue")
local connect = {}
local debounce = false	-- в состоянии перерисовывания
function ReloadScreen()
	
	task.wait(0.1)
	
	if debounce == false then
		debounce = true
		
		recept:ClearAllChildren()
		local uis = ui:Clone()
		uis.Parent = recept

		for _, nam in pairs(invent:GetChildren() ) do	-- имя элемента
			-- создание элемента списка
			local element = sample:Clone()				-- копия копии элемента
			element.Names.Text = nam.Name				-- укажем имя
			element.Amount.Text = nam.Value				-- укажем значение
			element.Parent = recept						-- поместим в список

			element.Button.Activated:Connect(function()	-- для активации выбора
				selected.Value = nam.Name
			end)
			
			if connect[nam] == nil then
				connect[nam] = nam.Changed:Connect(function()
					flag.Value = not flag.Value	-- при измении количества изменится флаг
				end)
			end
		end	
		
		debounce = false
	end
end
invent.ChildAdded:Connect(ReloadScreen)
invent.ChildRemoved:Connect(ReloadScreen)
ReloadScreen()

flag.Changed:Connect(ReloadScreen)	-- флаг на изменение количества

Проверяем. Всё вроде как работает, но.. что-то не так. И это самое «не так» обнаружилось благодаря ранее сделанному индикатору текущей сытости. Мы съедаем ягодку, сытость увеличивается и.. тут же начинает уменьшаться от предыдущего значения. Отсюда и выяснилось что мы работает на стороне клиента (как и ранее созданный скрипт крафта) и соответственно изменения не передаются на сервер. В следствии всего этого, все наши изменения ни к чему не приводят, ведь серверные данные не меняются и что творится на клиенте — проблемы клиента.

И тут наступает новый момент просветления — раз мы можем на клиенте делать что хотим, то можем и накрафтить сколько чего угодно. И да, и нет. Т.к. сервер об этом не в курсе, смысла в этом тоже не будет. Ведь в инвентаре сервера этих вещей не появится и соответственно, использовать их не получится.

Уведомить сервер мы можем только через удалённый вызов. А значит нам нужно добавить:

  • связующий[ие] RemoteFunction или RemoteEvent в наш ReplicatedStorage
  • вызов из скриптов крафта и инвентаря в сторону сервера
  • обработчик вызовов на сервере, с проверками на корректность исполнения

По итогу нам нужно будет не только написать серверный скрипт, но и изменить клиентские скрипты (оттуда мы сразу уберём все что в нажатиях кнопок и перенесём в серверный скрипт). Приступаем.

Для начала, мы создадим папочку содержащую все варианты вызовов между скриптами, с соответствующими названиями:

После чего сделаем болванку в ServerScriptService нашего управляющего скрипта:

-- Главный, управляющий игрой, скрипт

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- объявляем все возможные удалённые вызовы на приём
local remoteFunction = ReplicatedStorage.Remote.CommandFunction
local remoteEvent = ReplicatedStorage.Remote.CommandEvent
local remoteBindEvent = ReplicatedStorage.Remote.CommandBindEvent
local remoteBindFunction = ReplicatedStorage.Remote.CommandBindFunction

-- Это и будет наш главный мозг и контролёр
local function Command(player, cmd, par1, par2, par3)
	local result = nil
	
	--
	
	return result
end
-- объявляем все возможные удалённые вызовы на приём
remoteFunction.OnServerInvoke = Command
remoteEvent.OnServerEvent:Connect(Command)
remoteBindEvent.Event:Connect(Command)
remoteBindFunction.OnInvoke = Command

Конечно главное это наша чудная функция. «Чудная» потому что в ней сделано одно допущение — что всегда присутствует player в вызове. На самом же деле это не так — BindableEvent и BindableFunction не передают параметра вызывающего игрока, потому как используются для вызовов внутри связки Sever-Server или Client-Client. Как следствие, если мы таки будем их использовать, то надо это иметь ввиду и передавать какую-нибудь заглушку вместо игрока (когда его у нас реально не будет).

Теперь нам остаётся только добавить пару команд (с проверками) на выполнение запросов от клиентской части игры, т.е. при нажатии кнопки интерфейса. Для начала наша функция принимает вид:

local function Command(player, cmd, par1, par2, par3)
	local result = nil
	
	-- 
	if cmd == "Craft" then	-- получена команда крафта
		--
	end
	
	if cmd == "Used" then	-- получена команда использования
		--
	end
	
	return result
end

Теперь же начнём её наполнение. И вот тут уже надо будет делать всё по взрослому — со всеми возможными проверками корректности выполнения команды. Ведь если упадёт серверный скрипт, то игра будет сломана для абсолютно всех игроков. В отличии от сломавшегося локального скрипта, что нарушит игру только конкретному игроку.

Для начала добавим в скрипт инвентаря в начало строчку со ссылкой на RemoteEvent:

local remoteEvent = ReplicatedStorage.Remote.CommandEvent

А после изменим часть с нажатием кнопки на экране:

-- выбор предмета
function SelectUse()	-- раз сюда попали, значит проверки уже сделаны
	local val = selected.Value	-- что именно выбрали
	
	remoteEvent:FireServer("Used", val)	-- вызываем сервер		
end
info.btnUse.Activated:Connect(SelectUse)

А всё что было в данной функции перенесём в функцию на сервере, добавив контроля:

	if cmd == "Used" then	-- получена команда использования
		-- par1 = название того, что мы использовали
		local element = used[par1]
		local param = player.Statuses:FindFirstChild(element.Param)
		if param then
			-- Убавим в количестве
			local inv = player.Inventory:FindFirstChild(par1)
			if inv then
				inv.Value = inv.Value - 1
				if inv.Value == 0 then
					inv:Destroy()	-- уничтожим, раз кончилось
				end
				
				-- Прибавим в состоянии
				param.Value = param.Value + element.Delta
				if param.Value > config.SatietyMaximum then	-- не более максимума
					param.Value = config.SatietyMaximum
				end
			else
				warn("Не найдено", par1, "для использования")
			end
			-- result = true	-- не нужно, т.к. это вызывается по ивенту
		else
			warn("Параметр", element.Param, "не найден!")
		end
	end

Проверили. Всё работает, теперь как задумано. Произведём аналогичные действия со скриптом для GUI крафта и его серверной реализации. Но будет и отличие — это окно у нас не перерисовывается (ага, косяк с нашей стороны — нет), а потому уже будем использовать вызов удалённой функции вместо события.

Добавляем в начало:

local remoteFunction = ReplicatedStorage.Remote.CommandFunction

И соответственно код нажатия кнопки так же будет изменён:

-- создание предмета
function Craft()
	local val = selected.Value	-- что именно выбрали для крафта
	
	local flag = remoteFunction:InvokeServer("Craft", val)	-- вызываем сервер

	if flag then
		info.btnCraft.Interactable = true
		info.btnCraft.TextColor3 = Color3.new(0, 1, 0)
	else
		info.btnCraft.Interactable = false
		info.btnCraft.TextColor3 = Color3.new(1, 0, 0)
	end
end
info.btnCraft.Activated:Connect(Craft)

Теперь очередь куска в серверном скрипте. И в итоге наш серверный скрипт примет такой вид:

-- Главный, управляющий игрой, скрипт

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- объявляем все возможные удалённые вызовы на приём
local remoteFunction = ReplicatedStorage.Remote.CommandFunction
local remoteEvent = ReplicatedStorage.Remote.CommandEvent
local remoteBindEvent = ReplicatedStorage.Remote.CommandBindEvent
local remoteBindFunction = ReplicatedStorage.Remote.CommandBindFunction

-- подключение таблиц
local used = require( ReplicatedStorage:WaitForChild("Resource"):WaitForChild("Used") )
local craftTable = require( ReplicatedStorage:WaitForChild("Resource"):WaitForChild("Craft") )
local config = require( ReplicatedStorage:WaitForChild("GameConfig") )

-- Это и будет наш главный мозг и контролёр
local function Command(player, cmd, par1, par2, par3)
	local result = nil
	
	print("Command:", player, cmd, par1, par2, par3)
	
	-- получена команда крафта
	if cmd == "Craft" then
		-- par1 - что мы собрались крафтить
		
		local element = craftTable[par1]
		
		local invent = player:FindFirstChild("Inventory")
		
		if invent then
			local correct = true
			-- проверим наличие ресурсов для крафта
			for a, b in pairs(element.Resource) do
				local res = invent:FindFirstChild(a)
				if res.Value < b then
					correct = false
				end
			end
			
			if correct == true then
				-- заберём ресурсы
				for a, b in pairs(element.Resource) do
					local res = invent:FindFirstChild(a)
					res.Value = res.Value - b
					-- если кончилось нужно удалить из инвентаря
					if res.Value == 0 then
						res:Destroy()
					end
				end

				-- добавим что скрафтили
				local new = invent:FindFirstChild(par1)
				if new == nil then	-- ещё нет в инвентаре
					new = Instance.new("NumberValue")	
					new.Name = par1
				end
				new.Value = new.Value + element.Result
				new.Parent = invent

				-- проверка можно ли ещё раз крафтить
				local flag = true	-- можно крафтить
				for a, b in pairs(element.Resource) do
					local res = invent:FindFirstChild(a) 
					if res then
						if b > res.Value then
							flag = false	-- не хватает ресурса
						end 
					else	-- нет ресурса
						flag = false
					end
				end
				
				result = flag	-- можно ли крафтить и далее
			else
				-- крафт не удался, не хватает ресурсов
				result = false
			end
		else
			warn("Проблема с инвентарём игрока", player)
		end
	end
	
	-- получена команда использования
	if cmd == "Used" then	-- получена команда использования
		-- par1 = название того, что мы использовали
		local element = used[par1]
		local param = player.Statuses:FindFirstChild(element.Param)
		if param then
			-- проверим наличие
			local inv = player.Inventory:FindFirstChild(par1)
			if inv then
				-- Убавим в количестве
				inv.Value = inv.Value - 1
				if inv.Value == 0 then
					inv:Destroy()	-- уничтожим, раз кончилось
				end
				
				-- Прибавим в состоянии
				param.Value = param.Value + element.Delta
				if param.Value > config.SatietyMaximum then	-- не более максимума
					param.Value = config.SatietyMaximum
				end
			else
				warn("Не найдено", par1, "для использования")
			end
			-- result = true	-- не нужно, т.к. это вызывается по ивенту
		else
			warn("Параметр", element.Param, "не найден!")
		end
	end
	
	return result
end
-- объявляем все возможные удалённые вызовы на приём
remoteFunction.OnServerInvoke = Command
remoteEvent.OnServerEvent:Connect(Command)
remoteBindEvent.Event:Connect(Command)
remoteBindFunction.OnInvoke = Command

Ну теперь то всё? Запускаем. Да вещи крафтятся, еда естся, но… Здоровье персонажа не восполняется! И правда, мы же скрипт регенерации ранее отключили. Когда у нас должна работать регенерация здоровья? Очевидно, что только тогда, когда мы достаточно сытые. Добавим в GameConfig пару строк:

	HealthRegen = 1,		-- Регенерация за интервал
	HealthSatiety = 50,		-- выше какого уровня работает регенерация 

И полезем править скрипт HealthModifier добавив туда проверку сытости и восполнения здоровья:

-- управление здоровьем персонажей игроков

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local config = require( ReplicatedStorage:WaitForChild("GameConfig") )

local timer = {}	-- массив таймеров игроков

while task.wait() do
	for _, player in pairs( Players:GetPlayers() ) do		-- для всех игроков
		
		-- проверим наличие таймера у игрока
		if not timer[player] then
			timer[player] = {}	-- создадим если его нет
		end
		
		local character = player.Character			-- персонаж игрока
		if character and character.Parent then		-- персонаж в игре
			
			-- персонаж может появится и раньше чем переменная сытости
			local toDel = character:FindFirstChild("Health")
			if toDel then
				toDel:Destroy()						-- удалить скрипт регенерации 
			end
			
			-- будем проверять наступление события по таймеру
			if not timer[player].Satiety then
				timer[player].Satiety = tick()		-- таймер ещё не был создан
			end
			
			local changeHealth = 0					-- флаг для изменения здоровья
			
			-- наступило время по таймеру сытости
			if tick() >= timer[player].Satiety + config.SatietyInterval then
				timer[player].Satiety = tick()		-- обновляем таймер
				
				local statuses = player:FindFirstChild("Statuses")	-- папка статусов
				if statuses then
					local satiety = statuses:FindFirstChild("Satiety")
					if satiety then									-- переменная сытости
						if satiety.Value > 0 then					-- если сытость больше 0
							satiety.Value = satiety.Value + config.SatietyDelta	-- уменьшаем значение
							if satiety.Value > config.HealthSatiety then		-- достаточно сыт
								changeHealth = config.HealthRegen	-- регенерация
							end
						else										-- иначе уже влияет на здоровье
							satiety.Value = 0						-- сытость в ноль
							changeHealth = config.HealthDelta		-- нужно менять здоровье
						end
					end
				end
			end
			
			-- а в финале скрипта - обработка здоровья
			if changeHealth ~= 0 then	-- нужно ли его менять
				if not timer[player].Health then
					timer[player].Health = tick()		-- таймер ещё не был создан
				end
				if tick() >= timer[player].Health + config.HealthInterval then
					timer[player].Health = tick()		-- обновляем таймер

					local humanoid = character:FindFirstChild("Humanoid")
					if humanoid then
						if humanoid.Health > 0  then		-- персонаж живой
							humanoid.Health = humanoid.Health + changeHealth
						end
					end
				end
			end
		end
	end
end

Запускаем и проверяем. Ждём пока оголодаем и здоровье станет меньше 100, после чего едим ягодки. Как только сытость становится больше 50, начинается и восстановление здоровья.

Ну вот, теперь точно минимальное всё — примитивнейший выживач готов.

Остаётся следовать традиции и приложить результирующий плейс:


Поделиться...

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *