How good overall is my placement module?

Hello, I recently released a third placement module that handles most things you need for a sandbox tycoon placement system (example: Miners Haven). How clean, easy to understand and easy to use is the module?

Here is a sample place for you to use:
placement.rbxl (39.1 KB)

I mainly want to improve things (if there are some) that may not be the best such as repeated code, inefficient code or anything else that could be wrong with the module.

Module Code
-- SETTINGS

-- Bools
local interpolation = true -- Toggles interpolation (smoothing)
local moveByGrid = true -- Toggles grid system
local collisions = true -- Toggles collisions
local buildModePlacement = false -- Toggles "build mode" placement
local displayGridTexture = true -- Toggles the grid texture to be shown when placing
local smartDisplay = false -- Toggles smart display for the grid. If true, it will rescale the grid texture to match your gridsize
local enableFloors = true -- Toggles if the raise and lower keys will be enabled
local transparentModel = true -- Toggles if the model itself will be transparent

-- Color3
local collisionColor = Color3.fromRGB(255, 75, 75) -- Color of the hitbox when colliding
local hitboxColor = Color3.fromRGB(75, 255, 75) -- Color of the hitbox while not colliding

-- Integers
local maxHeight = 100 -- Max height you can place objects (in studs)
local floorStep = 10 -- The step (in studs) that the object will be raised or lowered
local rotationStep = 90 -- Rotation step

-- Numbers
local hitboxTransparency = 0.8 -- Hitbox transparency when placing
local transparencyDelta = 0.6 -- Transparency of the model itself (transparentModel must equal true)
local lerpSpeed = 0.7 -- speed of interpolation. 0 = no interpolation, 0.9 = major interpolation
local placementCooldown = 0.5 -- How quickly the user can place down objects (in seconds)

-- Other
local gridTexture = "rbxassetid://2415319308"

-- DO NOT EDIT PAST THIS POINT UNLESS YOU KNOW WHAT YOUR DOING.

local placement = {}

placement.__index = placement

-- Essentials
local runService = game:GetService("RunService")
local userInputService = game:GetService("UserInputService")

local player = game.Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local mouse = player:GetMouse()	

-- math/cframe functions
local clamp = math.clamp
local floor = math.floor
local abs = math.abs
local max = math.max
local pi = math.pi

local cframe = CFrame.new
local anglesXYZ = CFrame.fromEulerAnglesXYZ

-- Constructor variables
local grid
local itemLocation
local rotateKey
local terminateKey
local raiseKey
local lowerKey

-- Activation vatiables
local plot
local object

-- bools
local canPlace
local isColliding
local stackable
local smartRot
local canActivate = true
local currentRot = false

-- values used for calculations
local speed

local posX
local posY
local posZ
local rot
local x, z
local cx, cz

local lowerXBound
local upperXBound

local lowerZBound
local upperZBound

local initialY

-- collision variables
local collisionPoints
local collisionPoint
local collided

-- states
local states = {
	"movement",
	"placing",
	"colliding",
	"in-active"
}

local currentState = 4
local lastState = 4

-- other
local placedObjects
local loc
local primary
local lastPlacement = {}
local humanoid = character:WaitForChild("Humanoid")

local function setCurrentState(state)
	currentState = clamp(state, 1, 4)
	lastState = currentState
end

-- Changes the color of the hitbox depending on the current state
local function editHitboxColor()
	if currentState == 3 then
		object.PrimaryPart.Color = collisionColor
	else
		object.PrimaryPart.Color = hitboxColor
	end
end

-- Checks for collisions on the hitbox
local function checkHitbox()
	if object and collisions then
		setCurrentState(1)
		
		collisionPoint = object.PrimaryPart.Touched:Connect(function() end)
		collisionPoints = object.PrimaryPart:GetTouchingParts()
		
		for i = 1, #collisionPoints do
			if not collisionPoints[i]:IsDescendantOf(object) and not collisionPoints[i]:IsDescendantOf(character) then
				setCurrentState(3)
				
				break
			end
		end
		
		collisionPoint:Disconnect()
		
		return collided
	end
end

-- switches the floor depending on the value given
local function editFloor(f)
	if enableFloors and not stackable then
		if f == 1  then
			posY = posY + floor(abs(floorStep))
		else
			posY = posY - floor(abs(floorStep))
		end
	end
end

-- handles the grid texture
local function displayGrid()
	if displayGridTexture then
		local gridTex = Instance.new("Texture")
		
		gridTex.Name = "GridTexture"
		gridTex.Texture = gridTexture
		gridTex.Parent = plot
		gridTex.Face = Enum.NormalId.Top
		
		gridTex.StudsPerTileU = 2
		gridTex.StudsPerTileV = 2
		
		if smartDisplay then
			gridTex.StudsPerTileU = grid
			gridTex.StudsPerTileV = grid	
		end
	end
end

local function rotate()
	if smartRot then
		if currentRot then
			rot = rot + rotationStep
		else 
			rot = rot - rotationStep
		end
	else
		rot = rot + rotationStep
	end
	
	currentRot = not currentRot
end

local function round(n)
	if (n - (n % 0.1)) - (n - (n % 1)) < 0.5 then
		n = n - (n % 1)
	else
    	n = (n - (n % 1)) + 1
	end
	
	return n
end

local function calculateYPos(tp, ts, o)
	return (tp + ts / 2) + o / 2
end

local function bounds()
	if currentRot then
		lowerXBound = plot.Position.X - (plot.Size.X / 2) 
		upperXBound = plot.Position.X + (plot.Size.X / 2) - primary.Size.X
		
		lowerZBound = plot.Position.Z - (plot.Size.Z / 2)	
		upperZBound = plot.Position.Z + (plot.Size.Z / 2) - primary.Size.Z
	else
		lowerXBound = plot.Position.X - (plot.Size.X / 2) 
		upperXBound = plot.Position.X + (plot.Size.X / 2) - primary.Size.Z
		
		lowerZBound = plot.Position.Z - (plot.Size.Z / 2)	
		upperZBound = plot.Position.Z + (plot.Size.Z / 2) - primary.Size.X
	end
	
	posX = clamp(posX, lowerXBound, upperXBound)
	posZ = clamp(posZ, lowerZBound, upperZBound)
end

-- Calculates the position of the object
local function calculateItemLocation()
	x, z = mouse.Hit.X, mouse.Hit.Z
	
	if moveByGrid then
		if x % grid < grid / 2 then
			posX = round(x - (x % grid))
		else
			posX = round(x + (grid - (x % grid)))
		end
		
		if z % grid < grid / 2 then
			posZ = round(z - (z % grid))
		else
			posZ = round(z + (grid - (z % grid)))
		end
		
		if currentRot then
			cx = primary.Size.X / 2
			cz = primary.Size.Z / 2
		else
			cx = primary.Size.Z / 2
			cz = primary.Size.X / 2
		end
	else
		posX = x
		posZ = z
	end
	
	if stackable and mouse.Target then
		posY = calculateYPos(mouse.Target.Position.Y, mouse.Target.Size.Y, primary.Size.Y)
	end

	posY = clamp(posY, initialY, maxHeight + initialY)
	
	bounds()
end

local function getFinalCFrame()
	return cframe(posX, posY, posZ) * cframe(cx, 0, cz) * anglesXYZ(0, rot * pi / 180, 0)
end

-- Sets the position of the object
local function translateObj()
	if currentState ~= 4 then
		calculateItemLocation()
		checkHitbox()
		editHitboxColor()
		
		object:SetPrimaryPartCFrame(primary.CFrame:Lerp(cframe(posX, posY, posZ) * cframe(cx, 0, cz) * anglesXYZ(0, rot * pi / 180, 0), speed))
	end
end

-- handles user input
local function getInput(input, gpe)
	if currentState ~= 4 then
		if input.KeyCode == raiseKey then
			editFloor(1)
		elseif input.KeyCode == lowerKey then
			editFloor(2)
		elseif input.KeyCode == terminateKey then
			placement:terminate()
		elseif input.KeyCode == rotateKey then
			rotate()
		end
	end
end

local function coolDown(plr, cd)
	if lastPlacement[plr.UserId] == nil then
		lastPlacement[plr.UserId] = os.time()
		
		return true
	else
		if os.time() - lastPlacement[plr.UserId] >= cd then
			lastPlacement[plr.UserId] = os.time()
			
			return true
		else
			return false
		end
	end
end

-- Verifys that the plane which the object is going to be placed upon is the correct size
local function verifyPlane()	
	if plot.Size.X % grid == 0 and plot.Size.Z % grid == 0 then
		return true
	else
		return false
	end
end

-- Checks if there are any problems with the users setup
local function approveActivation()
	if not verifyPlane() then
		warn("The object that the model is moving on is not scaled correctly. Consider changing it.")
	end
	
	if grid > max(plot.Size.X, plot.Size.Z) then 
		error("Grid size is larger than the plot size. To fix this, try lowering the grid size.")
	end
end

-- Sets up placement
function placement.new(g, objs, r, t, u, l)
	local data = {}
	local metaData = setmetatable(data, placement)
	
	grid = abs(tonumber(g))
	itemLocation = objs
	rotateKey = r
	terminateKey = t
	raiseKey = u
	lowerKey = l
	
	data.gridsize = grid
	data.items = objs
	data.rotate = rotateKey
	data.cancel = terminateKey
	data.raise = raiseKey
	data.lower = lowerKey
	
	return data 
end

function placement:getCurrentState()
	return states[currentState]
end

function placement:pauseCurrentState()
	lastState = currentState
	
	if object then
		currentState = states[4]
	end
end

function placement:resume()
	if object then
		setCurrentState(lastState)
	end
end

-- Terminates placement
function placement:terminate()
	stackable = nil
	canPlace = nil
	smartRot = nil
	
	object:Destroy()
	object = nil
	
	if displayGridTexture then
		for i, v in next, plot:GetChildren() do
			if v then
				if v.Name == "GridTexture" and v:IsA("Texture") then
					v:Destroy()
				end
			end
		end
	end
	
	setCurrentState(4)
	canActivate = true
	
	return
end

-- Requests to place down the object
function placement:requestPlacement(func)
	if currentState ~= 4 or currentState ~= 3 and object then
		local cf
		
		calculateItemLocation()
		
		if coolDown(player, placementCooldown) then
			if buildModePlacement then
				cf = getFinalCFrame()
				
				checkHitbox()
				setCurrentState(2)
				
				if currentState == 2 then
					func:InvokeServer(object.Name, placedObjects, loc, cf, collisions)
					
					setCurrentState(1)
				end
			else
				cf = getFinalCFrame()
				
				checkHitbox()
				setCurrentState(2)
				
				if currentState == 2 then
					if func:InvokeServer(object.Name, placedObjects, loc, cf, collisions) then
						placement:terminate()
					end
				end
			end
		end
	end
end

-- Activates placement
function placement:activate(id, pobj, plt, stk, r)
	if object then
		object:Destroy()
		object = nil
	end
	
	plot = plt
	object = itemLocation:FindFirstChild(tostring(id))
	placedObjects = pobj
	loc = itemLocation
	
	approveActivation()
	
	object = itemLocation:FindFirstChild(id):Clone()
	
	for i, o in next, object:GetDescendants() do
		if o then
			if o:IsA("Part") or o:IsA("UnionOperation") or o:IsA("MeshPart") then
				o.CanCollide = false
				
				if transparentModel then
					o.Transparency = o.Transparency + transparencyDelta
				end
			end
		end
	end
	
	object.PrimaryPart.Transparency = hitboxTransparency
	
	stackable = stk
	smartRot = r
	
	if not stk then
		mouse.TargetFilter = placedObjects
	else
		mouse.TargetFilter = object
	end
	
	if buildModePlacement then
		canActivate = true
	else
		canActivate = false
	end
	
	initialY = calculateYPos(plt.Position.Y, plt.Size.Y, object.PrimaryPart.Size.Y)
	posY = initialY
	
	speed = 0
	rot = 0
	currentRot = true
	
	translateObj()
	displayGrid()
	editHitboxColor()
	
	if interpolation then
		speed = clamp(abs(tonumber(1 - lerpSpeed)), 0, 0.9)
	else
		speed = 1
	end
	
	primary = object.PrimaryPart
	object.Parent = pobj
	
	setCurrentState(1)
end

runService:BindToRenderStep("Input", Enum.RenderPriority.Input.Value, translateObj)
userInputService.InputBegan:Connect(getInput)

return placement

Thanks!

Please embed your scripts here, rather than use a downloadable file.

Ok I did that. When I was writing the topic it said to include a place file but I could be blind and misunderstood something.

1 Like

What’s your objective with this line of code (in checkHitbox)?


There is a better way of doing this. My round function:

-- Untested function, going off of top of my head
local function round(number:number) -- Using LuaU to speed code up
    local decimal_placement = 1 -- Round to nearest place. 1=whole number, 10=tenth, 100=100th, etc.
    return ( number%(1/decimal_placement) > 1/decimal_placement/2 ) and math.ceil(number*decimal_placement)/decimal_placement or math.floor(number*decimal_placement)/decimal_placement
    -- I usually keep stuff on one line, but can be converted into `if` function
end

Don’t use UserInputService for input like yours. Instead, use ContextActionService.


Last thing: Use LuaU syntax! For example, local n:number = 3 will load much faster than local n = 3.

Hope I helped :slight_smile:

1 Like

I did not create the collision function. I copied it off of a tutorial. I will add a comment crediting the creator.
As for the rest, thanks for the feedback! I will do my best to redo those parts.

1 Like

Remember Luau’s type system is not meant for production code at the moment. Opting into the type system does not improve performance, it only influences script analysis—things like linting and connecting to the backend LSP in a more meaningful way.

These changes are super awesome, but do not attempt to use this system in a published game, for it will register as a syntax error for as long as Luau’s type system is a beta feature.

2 Likes