Is this Too ambitious? Tool Framework

My Plan is to know the best way to do a Tool Framework in luau, with support for 3 categories, Melee, Misc and others, Melee having a global handler as the default, but all categories can be overwritten their functions, to support custom behaviors, and then make it work, so when a tool gets added to any player character, the behavior gets constructed, however if it’s already constructed, then don’t do it again, then add specific support for requiring the tools in the client or server only if specified, the tools can be constructed with tags or attributes

But is this what i am trying to do too complex?, i am having a mess right now, anyone have suggestions on where to start or in what to focus first? , then what to skip

And if anyone is wondering my use case, the game i am trying to do right now will contain shops, guns, and miscellaneous items like trackers, sodas, etc, i just need mostly all features so it won’t be a hassle in the future

5 Likes

If they are going to be be generic tools that share a large part of their functionality but need shared core features this might be a valid use case of OOP. The existing tool class tool has events* that you can piggy back off.

1 Like

If you understand how to do OOP, then you can make an inherited class list. Each unique tool has their own ModuleScript, containing information that you would like about the object. Every tool would absorb their ancestor’s functions, or have any functions overridden if defined in a more recent class.

Our first class, the very beginning would be a specific weapon. For example, a “Wooden Sword”.

local ancestorClass = require(MeleeClass)

local class = setmetatable({}, ancestorClass)
class.__index = class

function class.new(root, character)
	local new = setmetatable(ancestorClass.new(root), class)
	new.root = root
	new.Model = script.Model:Clone()

	new.Damage = 15

	return new
end

return class

obviously change the ancestorClass variable to the ancestor type, such as a melee weapon or whatever you would need.

Now for the first ancestorClass, a generic melee weapon class.

local ancestorClass = require(BaseClass)

local class = setmetatable({}, ancestorClass)
class.__index = class

function class.new(root)
	local new = setmetatable(ancestorClass.new(root), class)
	new.root = root
	
	return new
end

function class:M1()
	-- Weapon attack logic, such as animations, sounds, remote events, etc.
end

return class

Now for the final and very generic Tool class. Note that all tools will eventually return to this class and so the script structure is more unique.

local class = {}
class.__index = class

function class.new(root)
	local new = setmetatable({}, class)
	new.root = root
	new.__index = class
	
	return new
end

function class:EquipTool(tool, parent)
	-- Called when the tool needs to be cloned and parented to the player. 
end

Now for explaining why and what.

function class.new(root)
end

The new constructs the class, and will be called for when a tool is created. The base tool is structured differently as it will be the most highest parent, and so it creates the original metatable.

Each child class will override or add new data and functions. The __index is used to tell the metatable that it can collect all of the functions in the class itself, so that everything is collected when invoking class.new()

If you have no idea about OOP, then I can explain further. But this is my best attempt at explaining what you are trying to achieve.

4 Likes

I know most comments would be about OOP, so let me offer an alternative approach that could be more straightforward to implement and manage.

You can implement a system where ModuleScripts, intended to be different tool behaviors, have functions such as Equipped(), Unequipped(), and Activated(). Then you can have a main script that implements these functions to their respective tool events like this:

-- a "behavior" module script, in this example the module is named "Sword1Behavior"

local module = {}

-- insert code in functions
function module.Equipped() end
function module.Unequipped() end
function module.Activated() end

return module

-- main script handling the module scripts

local player: Player

local toolBehaviors = {
	["Sword1"] = {
		table.unpack(
			require(game.ServerScriptService.Sword1Behavior);
			-- insert more require() calls for more behaviors
		)
	}
}

player.Backpack.ChildAdded:Connect(function(tool: Tool)
	if tool:IsA("Tool") then
		local toolBehavior: {() -> ()} = toolBehaviors[tool.Name]
		
		for behaviorName, behaviorFunction in toolBehaviors do
			pcall(function()
				tool[behaviorName]:Connect(behaviorFunction)
			end)
		end
	end
end)
4 Likes

I’ll try this first, I’ll let you all know how it goes, And if you can, explain more

This method could also be useful, but I think it would lead to long-term, structural problems about server-client communication , Deciding if all the logic of every tool should be on the server or client is already a problem in itself but i guess that’s a problem for another time

1 Like

I would recommend making some basic OOP “class” like “Item” or “CustomTool” with basic methods like GetPlayer, then have a behavior script where you can add on custom logic

Item:

local players = game:GetService("Players")

local Item = {}
Item.__index = Item

function Item.new(tool: Tool, behavior: ModuleScript)
	local self = setmetatable({}, Item)
	
	self.Tool = tool
	self.Equipped = false
	self.Connections = {}
	self.Behavior = require(behavior)
	
	local function onEquipped(mouse: Mouse?)
		self.Equipped = true
	end
	
	local function onUnequipped()
		self.Equipped = false
	end
	
	table.insert(
		self.Connections,
		tool.Equipped:Connect(onEquipped)
	)
	table.insert(
		self.Connections,
		tool.Unequipped:Connect(onUnequipped)
	)
	
	if type(self.Behavior["new"]) == "function" then
		Behavior.new(self)
	end
	
	return self
end

--// Basic methods

function Item:GetPlayer()
	local player = nil
	
	if self.Equipped then
		player = players:GetPlayerFromCharacter(self.Tool.Parent)
	elseif self.Tool.Parent:IsA("Backpack") then
		player = self.Tool.Parent.Parent
	end
	
	return player
end

return Item

Behavior:

local behavior = {}

function behavior.new(self)
	--// Add extra stuff
	
	function self:Shoot()
		print("Shoot")
	end
	
	self:Shoot()
end

return behavior

That way you can have basic shared functionality (Like when the tool is equipped, make self.Equipped = true), then you can add on custom stuff.

For your system, just have 3 behaviors that are meant for their own stuff, like having some general behavior script for a melee weapon.

1 Like

This is the hierarchy i got for now

I kinda got it to work, the server-side part for now, Now all that is left is doing the client side (god save me)

In case someone wants to give me a opinion on how i am doing things, here’s all the code :

DefaultMelee :

-- DefaultMelee
return function(self)
	
	warn("Default Melee Enabled")
	
	function self:OnEquip()
		print("Equipped default melee:", self.Tool.Name)
	end
end

Sword :

-- Sword
return function(self)
	self.Damage = 25
	warn("Sword behavior enabled")
	
	function self:M1()
		print("Sword m1")
	end
end

BaseTool :

-- BaseTool
local Players = game:GetService("Players")

local BaseTool = {}
BaseTool.__index = BaseTool

function BaseTool.new(tool: Tool)
	local self = setmetatable({}, BaseTool)

	self.Tool = tool
	self.Connections = {}
	self.Equipped = false
	self.Player = nil

	self:_setup()

	return self
end

function BaseTool:_setup()
	warn("Setting base tool")
	
	table.insert(self.Connections,
		self.Tool.Equipped:Connect(function()
			self.Equipped = true
			self.Player = Players:GetPlayerFromCharacter(self.Tool.Parent)
			if self.OnEquip then
				self:OnEquip()
			end
		end)
	)

	table.insert(self.Connections,
		self.Tool.Unequipped:Connect(function()
			self.Equipped = false
			if self.OnUnequip then
				self:OnUnequip()
			end
		end)
	)
end

function BaseTool:Destroy()
	for _, conn in self.Connections do
		conn:Disconnect()
	end
end

return BaseTool

Melee :

-- Melee
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ToolFramework = ReplicatedStorage:FindFirstChild("ToolFramework")
local CoreFolder = ToolFramework:FindFirstChild("Core")

local BaseTool = require(CoreFolder:FindFirstChild("BaseTool"))

local Melee = setmetatable({}, BaseTool)
Melee.__index = Melee

function Melee.new(tool)
	local self = setmetatable(BaseTool.new(tool), Melee)

	self.Damage = 10
	self.Cooldown = 0.5

	return self
end

function Melee:M1()
	print("Default melee attack")
end

return Melee

Registry :

-- Registry
return {
	Default = {
		Class = "Melee",
		DefaultBehavior = "DefaultMelee"
	},

	Sword = {
		Class = "Melee",
		Behavior = "Sword"
	},

	Tracker = {
		Class = "Misc",
		Behavior = "Tracker"
	}
}

ToolBinder ServerScript :

-- ToolBinder
task.wait()

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

local ToolFramework = ReplicatedStorage:FindFirstChild("ToolFramework")

local CoreFolder = ToolFramework:FindFirstChild("Core")
local CategoriesFolder = ToolFramework:FindFirstChild("Categories")
local BehaviorsFolder = ToolFramework:FindFirstChild("Behaviors")

local Registry = require(ToolFramework:FindFirstChild("Registry"))

local Classes = {
	Melee = require(CategoriesFolder:FindFirstChild("Melee")),
	Misc = require(CategoriesFolder:FindFirstChild("Misc")),
}

local Behaviors = {
	DefaultMelee = require(BehaviorsFolder:FindFirstChild("DefaultMelee")),
	Sword = require(BehaviorsFolder:FindFirstChild("Sword")),
}

local Constructed = {} -- to prevent re-instance

local function construct(tool)
	if Constructed[tool] then
		return
	end
	
	warn("Tool Constructing .. "..tool.Name)

	local tags = CollectionService:GetTags(tool)
	warn("tags for "..tool.Name)
	warn(tags)
	
	local config
	for _, tag in tags do
		warn(tag)
		if Registry[tag] then
			config = Registry[tag]
			break
		end
	end
	
	warn(config)
	
	--if not config then
	--	warn("no config detected")
	--	return
	--end
	if not config then
		warn("No specific config found, using default")
		config = Registry.Default
	end
	

	local class = Classes[config.Class]
	if not class then return end

	local object = class.new(tool)

	-- default behavior
	if config.DefaultBehavior then
		Behaviors[config.DefaultBehavior](object)
	end

	-- override behavior
	if config.Behavior then
		Behaviors[config.Behavior](object)
	end

	Constructed[tool] = object
end

CollectionService:GetInstanceAddedSignal("Tool"):Connect(construct)

print("Tool Initialized")

-- Handle existing tools
--for _, tool in CollectionService:GetTagged("Tool") do
--	construct(tool)
--end
1 Like

I would make a script to setup your classes for you.

for _, module in replicatedFirst.Items:QueryDescendants("ModuleScript") do
	get.Items[module.Name] = require(module)
end

This one grabs every module script and registers it into the root.


This is how I structure my classes.

You do your weapon modules like this

-- Sword
return function(self)
	self.Damage = 25
	warn("Sword behavior enabled")
	
	function self:M1()
		print("Sword m1")
	end
end

But I recommend to make it a module script that returns a table, not a function. A table can contain all of the important data without creating several new instances of that data that already exists. If you had organized your code into something like:

-- Default Melee
local class = setmetatable({}, baseClass)
class.__index = class

function weaponClass.new()
	local new = setmetatable({}, class)
	new.Damage = 25

	return new
end

function weaponClass:M1()
	
end

function weaponClass:OnEquip()

end

Leaving the M1 function inside of the Sword class instead of the DefaultMelee class defeats the purpose of making your code inherited. Also the other replies are a bit of misdirection as they aren’t organized like how I explained. All of the classes I showed can be handled both on the client and server, with just specifying on each item type.

Default Melee Class:
function class:MouseButton1Server or function class:MouseButton1Client

1 Like

Thanks!, I have some questions, what is “get” in your example?, and by Saying i would need to make it a table , you meant something like this ?

local DefaultMelee = require(path.To.DefaultMelee)

local Sword = setmetatable({}, DefaultMelee)
Sword.__index = Sword

function Sword.new()
	local self = setmetatable(DefaultMelee.new(), Sword)
	self.Damage = 25
	return self
end

function Sword:M1() -- I won't do this if it's not needed at all
	print("Sword M1")
end

return Sword

You’re doing pretty good, just have a system in place for sub-behaviors

Basically you can have generic behaviors like “Gun”, then have a sub behavior for “FullAutoGun”,

Thatway you can add on to, or make new functions for the BaseTool(ex: making a shoot function, and do some generic stuff like make self.Shooting = true), then in the full auto sub-behavior, you can change how the shoot function behaves even more by adding on to it

I would also recommend for your tools using this framework to have this setup

A Tool (Obviously)

Then under that tool, a configuration instance (So you can store data for your framework, like making ammo = 10, and maxammo = 10, and making reload time to 3 seconds)

Then optionally a model, most advanced frameworks I’ve seen use this,

Basically, instead of messing around with the old legacy Handle system for tools, we use models, you will have to manually weld or motor6d them to a character.

(Make sure RequireHandle is set to false on your tool if you aren’t using Handles)

This will also allow you do make the model of the tool appear on the character whenever it’s “Grabbed”, let me know if you need help I this I can provide some code showing on how to detect when the tool is “Grabbed” or “Dropped”, and then to attach the tool to the character of the tool.

1 Like

I also wouldn’t recommend making new OOP classes in your behaviors, instead do it once in the BaseTool module, then under that, just add onto the self table. thatway you can use variables like self.Tool or self.Equipped from the BaseTool, and then make new functions (This would be good if you are accessing your “object” outside of that script)

1 Like

I didn’t get it much but, then something like this could work ?

local Sword = {
	Damage = 25
}

function Sword:M1(self)
	print("Sword M1", self.Tool)
end

return Sword

To be honest i don’t know what method could be the best , but i see some other options

-- Sword 
local Sword = {}
Sword.Damage = 25

function Sword:M1()
	print("Sword m1")
end

return Sword
local Sword = {
	Damage = 25
}

function Sword:M1(self)
	print("Sword M1", self.Tool)
end

return Sword
return function(self)
	self.Damage = 25

	function self:M1()
		print("Sword m1")
	end
end
1 Like

Either 2 or 3 could work, they’re basically the same, I honestly don’t know which is better

I think making the method within the Self table, not in the module maybe is a bit better (option 3)?? To be honest, they’re pretty similar so it doesn’t matter.

1 Like

also if you’re gonna do option 3, just note that if you want to add onto other functions other then .new, (for example: adding onto equipped, you will have to do the method in option 2, so that way you can add onto many functions)

1 Like

at the end i decided to do this :

ToolBinder ServerScript :

-- ToolBinder
task.wait()

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

local ToolFramework = ReplicatedStorage:FindFirstChild("ToolFramework")

local CoreFolder = ToolFramework:FindFirstChild("Core")
local CategoriesFolder = ToolFramework:FindFirstChild("Categories")
local BehaviorsFolder = ToolFramework:FindFirstChild("Behaviors")

local Registry = require(ToolFramework:FindFirstChild("Registry"))

local Classes = {
	Melee = require(CategoriesFolder:FindFirstChild("Melee")),
	Misc = require(CategoriesFolder:FindFirstChild("Misc")),
}

local Behaviors = {
	DefaultMelee = require(BehaviorsFolder:FindFirstChild("DefaultMelee")),
	Sword = require(BehaviorsFolder:FindFirstChild("Sword")),
}

local Constructed = {} -- to prevent re-instance

local function ApplyBehavior(self, behavior)
	-- copy data
	for k, v in pairs(behavior) do
		if type(v) ~= "function" then
			self[k] = v
		end
	end

	-- bind functions don't recreate logi
	for k, v in pairs(behavior) do
		if type(v) == "function" then
			self[k] = function(_, ...)
				return v(self, ...)
			end
		end
	end
end

local function construct(tool: Tool)
	if Constructed[tool] then
		return
	end

	warn("Tool Constructing .. "..tool.Name)

	local tags = CollectionService:GetTags(tool)
	warn("tags for "..tool.Name)
	warn(tags)

	local config
	for _, tag in tags do
		warn(tag)
		if Registry[tag] then
			config = Registry[tag]
			break
		end
	end
	
	warn(config)

	if not config then
		warn("No specific config found, using default")
		config = Registry.Default
	end
	

	local class = Classes[config.Class]
	if not class then return end

	local object = class.new(tool)

	-- default behavior
	if config.DefaultBehavior then
		local behavior = Behaviors[config.DefaultBehavior]
		if behavior then
			ApplyBehavior(object, behavior)
		end
	end

	-- override behavior
	if config.Behavior then
		local behavior = Behaviors[config.Behavior]
		if behavior then
			ApplyBehavior(object, behavior)
		end
	end
	
	Constructed[tool] = object
end

CollectionService:GetInstanceAddedSignal("Tool"):Connect(construct)

print("Tool Initialized")

-- Handle existing tools
--for _, tool in CollectionService:GetTagged("Tool") do
--	construct(tool)
--end

Behaviors > DefaultMelee :

local DefaultMelee = {
	Damage = 10
}

function DefaultMelee:OnEquip()
	print("Equipped Default Melee", self.Tool)
	warn(self)
end

return DefaultMelee

Don’t look like this would be impossible for you. It looks like a polished starting point. That part may be too ambitious. Maybe simplify and stabilize first. I’d start over and work my way up to where you are now. Make it work with one tool, then one category, then multiple tools, then optimize. Once you have all that working, this could be the final polishing.

1 Like

Exactly! And things like function Sword:M1() can actually be moved to the Default Melee class, unless if you wanted different functionality for specifically that sword class. The way how it works is it first goes trough every parent class, for your case it would start at Sword → DefaultMelee → DefaultTool, and it pulls all of the functions and data from each parent class. Anything named the same will override the existing function/data.

As for my script that has get in it, get is just a variable that contains all of the module scripts that will be applied to the root of my code. OOP is a bit tricky in lua but you can figure it out with a handful of devforum posts and even the lua documentation itself provides nice explanations.

You can think of OOP as a center point for all of your code being the root, and in the initialization of all of your modules it goes through each script and sets up its constructor to be added to the root. So then all scripts are connected and you can call events through each module script, for example:

-- Initialization module
local get = {}
get.Modules = {}
function initialization.Begin()
	local root = {}
	root.InputModule = get.Modules.Input.new(root)

end
-- Input Module
local class = {}
class.__index = class
function class.new(root)
	local new = setmetatable({}, class)
	new.root = root
	
	return new
end

return class

Initialization will be called by a script in the StarterPlayerScripts area.
Note that in my code examples, the variable names are not something you have to follow it’s just how I format my code. A lot of people will use self in place of where I type new.

self is simply just how the code passes ‘itself’ around, which is why you use the __index. __index Allows for the class to interact with other variables using a colon operator. The colon operator is what passes the self argument, if you called functions with a dot operator then self is not passed by default and would have to be it’s own parameter.

Kinda complicated to understand at first but the more you work with it and more you get better at scripting the easier it gets. I don’t recommend doing every project in OOP, but it’s perfect for the long term development cycle of games.

1 Like

Thank you, I think what i have right now it’s kind of solid, let me show :


I deleted “DefaultMelee” because it was not needed, as the “Melee” Module in categories handles all the default global behavior if not custom behavior is applied

Behaviors > Sword :

-- Sword
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ToolFramework = ReplicatedStorage:WaitForChild("ToolFramework")

local Categories = ToolFramework:FindFirstChild("Categories")

local Melee = require(Categories:FindFirstChild("Melee"))

local Sword = setmetatable({}, Melee)
Sword.__index = Sword

function Sword.new(tool)
	local self = setmetatable(Melee.new(tool), Sword)

	self.Damage = 25
	warn("sword constructed")
	warn(self)
	
	return self
end

function Sword:OnEquip()
	print("Equipped Sword", self.Tool)
	warn(self)
end

return Sword

Categories > Melee :

-- Melee
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ToolFramework = ReplicatedStorage:FindFirstChild("ToolFramework")
local CoreFolder = ToolFramework:FindFirstChild("Core")

local BaseTool = require(CoreFolder:FindFirstChild("BaseTool"))

local Melee = setmetatable({}, BaseTool)
Melee.__index = Melee

function Melee.new(tool)
	local self = setmetatable(BaseTool.new(tool), Melee)

	self.Damage = 10
	self.Cooldown = 0.5
	
	warn("Constructed Melee")
	
	return self
end

function Melee:M1()
	print("Default melee attack")
end

return Melee

Core > BaseTool

-- BaseTool.lua
local Players = game:GetService("Players")

local BaseTool = {}
BaseTool.__index = BaseTool

function BaseTool.new(tool: Tool)
	local self = setmetatable({}, BaseTool)

	self.Tool = tool
	self.Connections = {}
	self.Equipped = false
	self.Player = nil

	self:_setup()

	return self
end



function BaseTool:_setup()
	warn("Setting base tool")
	
	table.insert(self.Connections,
		self.Tool.Equipped:Connect(function()
			self.Equipped = true
			self.Player = Players:GetPlayerFromCharacter(self.Tool.Parent)
			if self.OnEquip then
				self:OnEquip()
			end
		end)
	)

	table.insert(self.Connections,
		self.Tool.Unequipped:Connect(function()
			self.Equipped = false
			if self.OnUnequip then
				self:OnUnequip()
			end
		end)
	)
	
	table.insert(self.Connections,
		self.Tool.Activated:Connect(function()
			if not self.Equipped then return end
			if self.OnActivated then
				self:OnActivated()
			end
		end)
	)
	
end

function BaseTool:Destroy()
	for _, conn in self.Connections do
		conn:Disconnect()
	end
end

return BaseTool

Registry :

-- Registry
return {
	Default = {
		Class = "Melee"
	},

	Sword = {
		Class = "Sword"
	}
}

ToolBinder ServerScript :

-- ToolBinder
task.wait()

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

local ToolFramework = ReplicatedStorage:WaitForChild("ToolFramework")

local CategoriesFolder = ToolFramework:WaitForChild("Categories")
local ToolsFolder = ToolFramework:WaitForChild("Behaviors") 

local Registry = require(ToolFramework:WaitForChild("Registry"))


local Classes = {
	-- base categories
	Melee = require(CategoriesFolder:WaitForChild("Melee")),
	Misc = require(CategoriesFolder:WaitForChild("Misc")),

	-- specific tools (OOP classes)
	Sword = require(ToolsFolder:WaitForChild("Sword")),
}

local Constructed = {}

local function construct(tool: Tool)
	if Constructed[tool] then
		return
	end

	warn("Constructing:", tool.Name)

	local tags = CollectionService:GetTags(tool)

	local config
	for _, tag in tags do
		if Registry[tag] then
			config = Registry[tag]
			break
		end
	end

	-- fallback
	if not config then
		config = Registry.Default
	end

	local classModule = Classes[config.Class]
	if not classModule then
		warn("No class found for:", config.Class)
		return
	end

	local object = classModule.new(tool)

	Constructed[tool] = object
end

CollectionService:GetInstanceAddedSignal("Tool"):Connect(construct)

print("Tool System Initialized")

-- Optional: handle already tagged tools
--for _, tool in CollectionService:GetTagged("Tool") do
--	construct(tool)
--end

Now If you have the time, i would appreciate explaining if implementing client-side support would be easier now, but even so, i’ll see what i can do