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
33 changes: 32 additions & 1 deletion docs/Observers/tags.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Tags

The CollectionService allows developers to assign arbitrary tags to any instance in a game. The `observeTag` observer can be used to observe instances with specific tags. This can be used to designate specific behavior to an object when it has a given tag, and to clean up the behavior once the tag is removed.
The CollectionService allows developers to assign arbitrary tags to any instance in a game. The `observeTag` and `observeTags` observers can be used to monitor instances based on these tags. This is ideal for designating specific behavior to an object when it has a given set of tags and ensuring that behavior is cleaned up once any of the tags are removed.

```lua
-- Observe instances with the "Disco" tag:
Expand All @@ -20,6 +20,24 @@ Observers.observeTag("Disco", function(part: BasePart)
end)
```

```lua
-- Only turn on the disco if the part is "Disco" AND "Active":
Observers.observeTags({"Disco", "Active"}, function(part: BasePart)
local discoThread = task.spawn(function()
while true do
task.wait(0.2)
part.Color = Color3.new(math.random(), math.random(), math.random())
end
end)

return function()
task.cancel(discoThread)
-- Optionally reset the color when deactivated
part.Color = Color3.new(1, 1, 1)
end
end)
```

## Type-Checking

Note that the instance class is unknown to the observer. The above example assumes it is a `Part`, but that is not guaranteed. It is best to check that the type you're expecting is correct:
Expand Down Expand Up @@ -48,3 +66,16 @@ Observers.observeTag(
allowedAncestors
)
```

```lua
local allowedAncestors = { workspace }

-- Works for both observeTag and observeTags:
Observers.observeTags(
{"Disco", "Active"},
function(part: BasePart)
...
end,
allowedAncestors
)
```
24 changes: 24 additions & 0 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,30 @@ interface Observers {
ancestors?: Instance[],
) => () => void;

/**
* Observe instances with the given tags.
*
* ```ts
* Observers.observeTags({"MyTag1", "MyTag2"}, (instance) => {
* // Do something with `instance`.
* return () => {
* // Cleanup.
* // The instance is either gone, lost one or more tags, or moved to a non-allowed ancestor (if supplied).
* };
* });
* ```
*
* @param tags List CollectionService tags.
* @param callback Observer function. Runs for every instance with the given tag.
* @param ancestors Optional inclusion list of allowed ancestors. The default is to allow all ancestors.
* @returns Cleanup function.
*/
observeTags: <T extends Instance = Instance>(
tags: string,
callback: (instance: T) => (() => void) | void,
ancestors?: Instance[],
) => () => void;

/**
* Observes players in the game.
*
Expand Down
1 change: 1 addition & 0 deletions lib/init.luau
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ return {
observePlayer = require(script.observePlayer),
observeCharacter = require(script.observeCharacter),
observeLocalCharacter = require(script.observeLocalCharacter),
observeTags = require(script.observeTags),
}
254 changes: 254 additions & 0 deletions lib/observeTags.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
--!strict

local CollectionService = game:GetService("CollectionService")

type InstanceStatus = "__inflight__" | "__dead__"
type Callback<T> = (instance: T) -> (() -> ())?

--[=[
@within Observers

Creates an observer around a list of CollectionService tags. The given callback will fire for each instance
that has **all** of the given tags.

The callback should return a function, which will be called when the given instance loses any of the given tags,
is destroyed, or (if the `ancestors` table is provided) goes outside of the allowed ancestors.

The function itself returns a function that can be called to stop the observer. This will also call
any cleanup functions of currently-observed instances.

```lua
local stopObserver = Observers.observeTags({"MyTag1", "MyTag2"}, function(instance: Instance)
print("Observing", instance)

-- The "cleanup" function:
return function()
print("Stopped observing", instance)
end
end)

-- Optionally, the `stopObserver` function can be called to completely stop the observer:
task.wait(10)
stopObserver()
```

#### Ancestor Inclusion List
By default, the `observeTags` function will observe a tagged instance anywhere in the Roblox game
hierarchy. The `ancestors` table can optionally be used, which will restrict the observer to only
observe tagged instances that are descendants of instances within the `ancestors` table.

For instance, if a tagged instance should only be observed when it is in the Workspace, the Workspace
can be added to the `ancestors` list. This might be useful if a tagged model prefab exist somewhere
such as ServerStorage, but shouldn't be observed until placed into the Workspace.

```lua
local allowedAncestors = { workspace }

Observers.observeTags(
{"MyTag1", "MyTag2"},
function(instance: Instance)
...
end,
allowedAncestors
)
```
]=]
local function observeTags<T>(tags: { string }, callback: Callback<T>, ancestors: { Instance }?): () -> ()
local instances: { [Instance]: InstanceStatus | () -> () } = {}
local ancestryConn: { [Instance]: RBXScriptConnection } = {}
local tagConns: { [string]: { Added: RBXScriptConnection, Removed: RBXScriptConnection } } = {}

local isAlive = true

local function isGoodAncestor(instance: Instance)
if not instance:IsDescendantOf(game) then
return false
end

if ancestors == nil then
return true
end

for _, ancestor in ancestors do
if instance:IsDescendantOf(ancestor) then
return true
end
end

return false
end

local function hasAllTags(instance: Instance)
for _, tag in tags do
if not instance:HasTag(tag) then
return false
end
end
return true
end

local function attemptStartup(instance: Instance)
-- Mark instance as starting up:
instances[instance] = "__inflight__"

-- Attempt to run the callback:
task.defer(function()
if instances[instance] ~= "__inflight__" then
return
end

-- Run the callback in protected mode:
local success, cleanup = xpcall(function(inst: Instance)
local clean = callback(inst :: any)
if clean ~= nil then
assert(typeof(clean) == "function", "callback must return a function or nil")
end
return clean
end, debug.traceback :: (string) -> string, instance)

-- If callback errored, print out the traceback:
if not success then
local err = ""
local firstLine = string.split(cleanup :: any, "\n")[1]
local lastColon = string.find(firstLine, ": ")
if lastColon then
err = firstLine:sub(lastColon + 1)
end
warn(`error while calling observeTags({table.concat(tags, ", ")}) callback: {err}\n{cleanup}`)
return
end

if instances[instance] ~= "__inflight__" then
-- Requirements changed before callback completed; call cleanup immediately:
if cleanup ~= nil then
task.spawn(cleanup :: any)
end
else
-- Good startup; mark the instance with the associated cleanup function:
instances[instance] = cleanup :: any
end
end)
end

local function attemptCleanup(instance: Instance)
local cleanup = instances[instance]
instances[instance] = "__dead__"

if typeof(cleanup) == "function" then
task.spawn(cleanup)
end
end

local function onStateChanged(instance: Instance)
if not isAlive then
return
end

local shouldBeObserved = hasAllTags(instance) and isGoodAncestor(instance)
local currentStatus = instances[instance]

if shouldBeObserved then
if currentStatus == "__dead__" or currentStatus == nil then
if currentStatus == nil then
instances[instance] = "__dead__"
ancestryConn[instance] = instance.AncestryChanged:Connect(function()
onStateChanged(instance)
end)
end
attemptStartup(instance)
end
else
if currentStatus ~= "__dead__" and currentStatus ~= nil then
attemptCleanup(instance)
end
end
end

local function onInstanceAdded(instance: Instance)
if not isAlive then
return
end

if instances[instance] ~= nil then
onStateChanged(instance)
return
end

instances[instance] = "__dead__"

ancestryConn[instance] = instance.AncestryChanged:Connect(function()
onStateChanged(instance)
end)

onStateChanged(instance)
end

local function onInstanceRemoved(instance: Instance)
onStateChanged(instance)

local hasAnyTag = false
for _, tag in tags do
if instance:HasTag(tag) then
hasAnyTag = true
break
end
end

-- If it has no tags left from the list, stop tracking it:
if not hasAnyTag then
attemptCleanup(instance)
local ancestry = ancestryConn[instance]
if ancestry then
ancestry:Disconnect()
ancestryConn[instance] = nil
end
instances[instance] = nil
end
end

-- Hook up added/removed listeners for all tags:
for _, tag in tags do
tagConns[tag] = {
Added = CollectionService:GetInstanceAddedSignal(tag):Connect(onInstanceAdded),
Removed = CollectionService:GetInstanceRemovedSignal(tag):Connect(onInstanceRemoved),
}
end

-- Attempt to mark already-existing tagged instances right away:
task.defer(function()
if not isAlive then
return
end

for _, tag in tags do
for _, instance in CollectionService:GetTagged(tag) do
onInstanceAdded(instance)
end
end
end)

-- Full observer cleanup function:
return function()
isAlive = false

for _, conns in tagConns do
conns.Added:Disconnect()
conns.Removed:Disconnect()
end

-- Clear all instances:
local instance = next(instances)
while instance do
attemptCleanup(instance)
local ancestry = ancestryConn[instance]
if ancestry then
ancestry:Disconnect()
end
instance = next(instances)
end
instances = {}
ancestryConn = {}
end
end

return observeTags