Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions src/SimplePath/Signal.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
--------------------------------------------------------------------------------
-- Batched Yield-Safe Signal Implementation --
-- This is a Signal class which has effectively identical behavior to a --
-- normal RBXScriptSignal, with the only difference being a couple extra --
-- stack frames at the bottom of the stack trace when an error is thrown. --
-- This implementation caches runner coroutines, so the ability to yield in --
-- the signal handlers comes at minimal extra cost over a naive signal --
-- implementation that either always or never spawns a thread. --
-- --
-- API: --
-- local Signal = require(THIS MODULE) --
-- local sig = Signal.new() --
-- local connection = sig:Connect(function(arg1, arg2, ...) ... end) --
-- sig:Fire(arg1, arg2, ...) --
-- connection:Disconnect() --
-- sig:DisconnectAll() --
-- local arg1, arg2, ... = sig:Wait() --
-- --
-- Licence: --
-- Licenced under the MIT licence. --
-- --
-- Authors: --
-- stravant - July 31st, 2021 - Created the file. --
--------------------------------------------------------------------------------

-- The currently idle thread to run the next handler on
local freeRunnerThread = nil

-- Function which acquires the currently idle handler runner thread, runs the
-- function fn on it, and then releases the thread, returning it to being the
-- currently idle one.
-- If there was a currently idle runner thread already, that's okay, that old
-- one will just get thrown and eventually GCed.
local function acquireRunnerThreadAndCallEventHandler(fn, ...)
local acquiredRunnerThread = freeRunnerThread
freeRunnerThread = nil
fn(...)
-- The handler finished running, this runner thread is free again.
freeRunnerThread = acquiredRunnerThread
end

-- Coroutine runner that we create coroutines of. The coroutine can be
-- repeatedly resumed with functions to run followed by the argument to run
-- them with.
local function runEventHandlerInFreeThread(...)
acquireRunnerThreadAndCallEventHandler(...)
while true do
acquireRunnerThreadAndCallEventHandler(coroutine.yield())
end
end

-- Connection class
local Connection = {}
Connection.__index = Connection

function Connection.new(signal, fn)
return setmetatable({
_connected = true,
_signal = signal,
_fn = fn,
_next = false,
}, Connection)
end

function Connection:Disconnect()
assert(self._connected, "Can't disconnect a connection twice.", 2)
self._connected = false

-- Unhook the node, but DON'T clear it. That way any fire calls that are
-- currently sitting on this node will be able to iterate forwards off of
-- it, but any subsequent fire calls will not hit it, and it will be GCed
-- when no more fire calls are sitting on it.
if self._signal._handlerListHead == self then
self._signal._handlerListHead = self._next
else
local prev = self._signal._handlerListHead
while prev and prev._next ~= self do
prev = prev._next
end
if prev then
prev._next = self._next
end
end
end

-- Make Connection strict
setmetatable(Connection, {
__index = function(tb, key)
error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2)
end,
__newindex = function(tb, key, value)
error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2)
end
})

-- Signal class
local Signal = {}
Signal.__index = Signal

function Signal.new()
return setmetatable({
_handlerListHead = false,
}, Signal)
end

function Signal:Connect(fn)
local connection = Connection.new(self, fn)
if self._handlerListHead then
connection._next = self._handlerListHead
self._handlerListHead = connection
else
self._handlerListHead = connection
end
return connection
end

-- Disconnect all handlers. Since we use a linked list it suffices to clear the
-- reference to the head handler.
function Signal:DisconnectAll()
self._handlerListHead = false
end

-- Signal:Fire(...) implemented by running the handler functions on the
-- coRunnerThread, and any time the resulting thread yielded without returning
-- to us, that means that it yielded to the Roblox scheduler and has been taken
-- over by Roblox scheduling, meaning we have to make a new coroutine runner.
function Signal:Fire(...)
local item = self._handlerListHead
while item do
if item._connected then
if not freeRunnerThread then
freeRunnerThread = coroutine.create(runEventHandlerInFreeThread)
end
task.spawn(freeRunnerThread, item._fn, ...)
end
item = item._next
end
end

-- Implement Signal:Wait() in terms of a temporary connection using
-- a Signal:Connect() which disconnects itself.
function Signal:Wait()
local waitingCoroutine = coroutine.running()
local cn;
cn = self:Connect(function(...)
cn:Disconnect()
task.spawn(waitingCoroutine, ...)
end)
return coroutine.yield()
end

-- Make signal strict
setmetatable(Signal, {
__index = function(tb, key)
error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2)
end,
__newindex = function(tb, key, value)
error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2)
end
})

return Signal
46 changes: 26 additions & 20 deletions src/SimplePath.lua → src/SimplePath/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ local DEFAULT_SETTINGS = {

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

local Signal = require(script.Signal)

local function output(func, msg)
func(((func == error and "SimplePath Error: ") or "SimplePath: ")..msg)
func((if func == error then "SimplePath Error: " else "SimplePath: ")..msg)
end

local Path = {
StatusType = {
Idle = "Idle";
Expand All @@ -40,10 +44,10 @@ Path.__index = function(table, index)
if index == "Stopped" and not table._humanoid then
output(error, "Attempt to use Path.Stopped on a non-humanoid.")
end
return (table._events[index] and table._events[index].Event)
or (index == "LastError" and table._lastError)
or (index == "Status" and table._status)
or Path[index]
return if table._events[index] then table._events[index]
elseif index == "LastError" then table._lastError
elseif index == "Status" then table._status
else Path[index]
end

--Used to visualize waypoints
Expand All @@ -68,9 +72,9 @@ local function createVisualWaypoints(waypoints)
visualWaypointClone.Position = waypoint.Position
visualWaypointClone.Parent = workspace
visualWaypointClone.Color =
(waypoint == waypoints[#waypoints] and Color3.fromRGB(0, 255, 0))
or (waypoint.Action == Enum.PathWaypointAction.Jump and Color3.fromRGB(255, 0, 0))
or Color3.fromRGB(255, 139, 0)
if waypoint == waypoints[#waypoints] then Color3.fromRGB(0, 255, 0)
elseif waypoint.Action == Enum.PathWaypointAction.Jump then Color3.fromRGB(255, 0, 0)
else Color3.fromRGB(255, 139, 0)
table.insert(visualWaypoints, visualWaypointClone)
end
return visualWaypoints
Expand Down Expand Up @@ -199,13 +203,13 @@ function Path.new(agent, agentParameters, override)
end

local self = setmetatable({
_settings = override or DEFAULT_SETTINGS;
_settings = if override then override else DEFAULT_SETTINGS;
_events = {
Reached = Instance.new("BindableEvent");
WaypointReached = Instance.new("BindableEvent");
Blocked = Instance.new("BindableEvent");
Error = Instance.new("BindableEvent");
Stopped = Instance.new("BindableEvent");
Reached = Signal.new();
WaypointReached = Signal.new();
Blocked = Signal.new();
Error = Signal.new();
Stopped = Signal.new();
};
_agent = agent;
_humanoid = agent:FindFirstChildOfClass("Humanoid");
Expand All @@ -220,7 +224,7 @@ function Path.new(agent, agentParameters, override)

--Configure settings
for setting, value in pairs(DEFAULT_SETTINGS) do
self._settings[setting] = self._settings[setting] == nil and value or self._settings[setting]
self._settings[setting] = if self._settings[setting] == nil then value else self._settings[setting]
end

--Path blocked connection
Expand All @@ -238,7 +242,7 @@ end
--[[ NON-STATIC METHODS ]]--
function Path:Destroy()
for _, event in ipairs(self._events) do
event:Destroy()
event:Disconnect()
end
self._events = nil
if rawget(self, "_visualWaypoints") then
Expand Down Expand Up @@ -277,7 +281,7 @@ function Path:Run(target)
end

--Parameter check
if not (target and (typeof(target) == "Vector3" or target:IsA("BasePart"))) then
if not (target and (if typeof(target) == "Vector3" then typeof(target) == "Vector3" else target:IsA("BasePart"))) then
output(error, "Pathfinding target must be a valid Vector3 or BasePart.")
end

Expand All @@ -291,8 +295,9 @@ function Path:Run(target)
end

--Compute path
local pathValue = typeof(target) == "Vector3" and target
local pathComputed, _ = pcall(function()
self._path:ComputeAsync(self._agent.PrimaryPart.Position, (typeof(target) == "Vector3" and target) or target.Position)
self._path:ComputeAsync(self._agent.PrimaryPart.Position, if pathValue then pathValue else target.Position)
end)

--Make sure path computation is successful
Expand All @@ -307,7 +312,8 @@ function Path:Run(target)
end

--Set status to active; pathfinding starts
self._status = (self._humanoid and Path.StatusType.Active) or Path.StatusType.Idle
local statusValue = self._humanoid and Path.StatusType.Active
self._status = if statusValue then statusValue else Path.StatusType.Idle
self._target = target

--Set network owner to server to prevent "hops"
Expand All @@ -329,7 +335,7 @@ function Path:Run(target)
self._visualWaypoints = (self.Visualize and createVisualWaypoints(self._waypoints))

--Create a new move connection if it doesn't exist already
self._moveConnection = self._humanoid and (self._moveConnection or self._humanoid.MoveToFinished:Connect(function(...)
self._moveConnection = self._humanoid and (if self._moveConnection then self._moveConnection else self._humanoid.MoveToFinished:Connect(function(...)
moveToFinished(self, ...)
end))

Expand Down