Non Uniform Zones/Regions (point in polygon algorithm!)

Hello Roblox Developer Community! :heart:

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:_InitializeFaces(nodesFolder)

	return self
end

function Zone:SetHeight(height)
	self._Height = height
end

function Zone:_InitializeFaces(nodesFolder)
	local min = nodesFolder.Root.CFrame.Position.Y - (self._Height)

	local max = nodesFolder.Root.CFrame.Position.Y + self._Height

	local facesFolder = Instance.new("Folder")
	facesFolder.Name = "Faces"

	local cursor = nodesFolder.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 == nodesFolder.Root

	facesFolder.Parent = Workspace

	self._FacesFolder = facesFolder
end

function Zone:DoesContainPoint(point)
	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Whitelist

	-- 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)

	local intersectionCount = 0
	for _, face in pairs(self._FacesFolder:GetChildren()) do
		params.FilterDescendantsInstances = {face}

		local result = workspace:Raycast(point, direction, params)
		if result and result.Instance then
			intersectionCount += 1
		end
	end

	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


2

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)

2 Likes