Hello Roblox Developer Community!
Have you ever needed to add non-uniform/non-rectangular zones into your game? Now you can!
This code will perform what is called the Point in Polygon algorithm, using raycasts, to tell if a given point is inside a zone!
There is a tiny bit of setup for each zone, so follow the setup instructions below!
Copy this code into a module script, name it whatever you want
Code
local Workspace = game:GetService("Workspace")
local DEFAULT_MAX_HEIGHT = 256
local Zone = {}
Zone.__index = Zone
function Zone.new(nodesFolder)
local self = {}
setmetatable(self, Zone)
self._Height = DEFAULT_MAX_HEIGHT
self._Nodes = nodesFolder
self:_CalculateMiddle()
self:_CalculateFurthestPoint()
self:_InitializeFaces()
return self
end
function Zone:SetHeight(height)
self._Height = height
if self._FacesFolder then
self._FacesFolder:Destroy()
end
self:_InitializeFaces()
end
function Zone:_CalculateMiddle()
local total = Vector3.zero
local totalNodes = 0
for _, node in pairs(self._Nodes:GetChildren()) do
if not node:IsA("BasePart") then
continue
end
total += node.CFrame.Position
totalNodes += 1
end
local middle = total / totalNodes
self._Middle = middle
end
function Zone:_CalculateFurthestPoint()
local furthestPoint = self._Middle
local furthestDistance = 0
for _, node in pairs(self._Nodes:GetChildren()) do
if not node:IsA("BasePart") then
continue
end
local distance = (node.CFrame.Position - self._Middle).Magnitude
if distance > furthestDistance then
furthestDistance = distance
furthestPoint = node.CFrame.Position
end
end
self._FurthestPoint = furthestPoint
self._FurthestDistance = furthestDistance
end
function Zone:_InitializeFaces()
local min = self._Nodes.Root.CFrame.Position.Y - (self._Height)
local max = self._Nodes.Root.CFrame.Position.Y + self._Height
local facesFolder = Instance.new("Folder")
facesFolder.Name = "Faces"
local cursor = self._Nodes.Root
repeat
local nextNode = cursor.Connection.Attachment1.Parent
local here = cursor.Position
local there = nextNode.Position
local midpoint = (here + there) / 2
local delta = (there - here) * Vector3.new(1, 0, 1)
local length = delta.Magnitude
local y = (min + max) / 2
local position = Vector3.new(midpoint.X, y, midpoint.Z)
local cframe = CFrame.new(position, position + delta)
local face = Instance.new("Part")
face.Anchored = true
face.CanCollide = false
face.Transparency = 1
face.Color = Color3.new(1, 0.5, 0.5)
face.CFrame = cframe
face.Size = Vector3.new(0, max - min, length)
face.Parent = facesFolder
cursor = nextNode
until cursor == self._Nodes.Root
facesFolder.Parent = Workspace
self._FacesFolder = facesFolder
end
function Zone:DoesContainPoint(point)
local distance = (self._Middle - point).Magnitude
if distance > self._FurthestDistance then
return false
end
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Include
params.FilterDescendantsInstances = self._FacesFolder:GetChildren()
-- point in polygon algorithm demands an infinite ray, so we just
-- use one of arbitrary length in order to get a similar effect
local direction = Vector3.new(8192, 0, 0)
debug.profilebegin("Zone Point Check")
local intersectionCount = 0
local result = workspace:Raycast(point, direction, params)
while result do
intersectionCount += 1
local filteredDescendants = params.FilterDescendantsInstances
local index = table.find(filteredDescendants, result.Instance)
if index then
table.remove(filteredDescendants, index)
end
params.FilterDescendantsInstances = filteredDescendants
result = workspace:Raycast(point, direction, params)
end
debug.profileend()
return (intersectionCount % 2) == 1
end
function Zone:Destroy()
if self._FacesFolder then
self._FacesFolder:Destroy()
end
self._FacesFolder = nil
end
return Zone
Zone Setup
In order for a zone to work, it needs to have a set of nodes. (nodes are just baseparts). You can see my node setup below. I have 4 nodes. THERE HAS TO BE A NODE NAMED ROOT
As you can see in each node, there is an attachment (I name it the same name as the node for organization, its not required though), and there is an AlignPosition named “Connection” IT MUST BE NAMED CONNECTION
What you want to do with your nodes, is connect them using the AlignPosition Connection. You can turn on Constraint Details to see the blue lines pictured below
In order to “Connect” them, first start with the root node. Select the Connection, and scroll to the “Attachment0” and “Attachment1” properties, in the properties window.
Attachment0 must be the attachment of the connection, so the root connection must be the root attachment
Attachment1 must be the attachment inside the node you are connecting it to, in my case this was a node called “2”
When you select both attachments, the blue line will appear (if you have constraint details turned on), confirming that these 2 nodes are connected.
What you want to do now, is start in the next node (in my case thats node “2”) and connect it to the next node (in my case thats node “3”)
Now you will continue to connect all the nodes.
MAKE SURE TO CONNECT THE LAST NODE TO THE ROOT NODE, COMPLETING THE CYCLE. THIS IS REQUIRED!!!
You can add as many nodes wherever you like, just make sure they complete the cycle from root → node → node → … → root
ALSO MAKE SURE EACH NODE IS ONLY CONNECTED TO 2 OTHER NODES (2 blue lines coming out of it), THE CODE WILL START AT THE ROOT NODE AND HOP FROM NODE TO NODE UNTIL IT REACHES THE ROOT NODE AGAIN AND THEN THE ALGORITHM WILL FINISH
In order to use this module, require it in any script and call the .new() function, plugging the nodes folder (or model) into it
local zone = Zone.new(nodesFolder)
Code API
-
Zone.new(nodesFolder : Folder or Model) : Zone
This function will construct a new zone object using the nodes in the given folder! -
Zone:SetHeight(height : number)
This function is used to set the height of the zone, by default this is 256 studs, but you can set it to be lower or higher, note that there will be a roblox limit to how high the zone can be, this is because the zone is handled by parts, parts can only have certain lengths -
Zone:DoesContainPoint(point : Vector3) : boolean
returns a boolean on if the zone contains the point -
Zone:Destroy()
Zone creates some parts in the workspace when initialized, use this function if your zone is no longer needed, and it will clean up on the parts it created
Glitchiness
Due to parts not being infinitely thin, sometimes when a point lines up with the edge of a zone, it will glitch out and think its inside or outside a zone, when the opposite is true. In order to go around this, i usually check 3 points, the point itself, a point in front of it, and a point to the side of it. If all 3 points agree, then the original point must be in/out of the zone!
local character = Player.Character
if not character then
return
end
local pivot = character:GetPivot()
local isInside = self._Zone:DoesContainPoint((pivot).Position)
local allAgree = (self._Zone:DoesContainPoint((pivot * CFrame.new(0, 0, -0.1)).Position) == isInside)
and (self._Zone:DoesContainPoint((pivot * CFrame.new(-0.1, 0, 0)).Position) == isInside)
if not allAgree then
return
end
local index = table.find(self._PlayersInZone, Player)
if isInside then
if not index then
table.insert(self._PlayersInZone, Player)
self.PlayerEnteredZone:Fire(Player)
end
else
if index then
table.remove(self._PlayersInZone, index)
self.PlayerExitedZone:Fire(Player)
end
end
Helpful Tip
The zone will create a folder called “Faces” in the workspace, these are just parts, so you can take them and make them non-transparent on run-time. You can see where the walls of the zone are.
IN ORDER FOR THIS MODULE TO WORK, ALL OF THE NODES MUST BE THERE AT TIME OF .new(). THEREFORE IF YOU’RE USING STREAMING ENABLED, MAKE SURE THE NODES STREAM IN AS A UNIT (Atomic)