--!strict
--[[
	@class MovingPlatform
	Moves a platform between two attachment points.

	Requires:
	- Point1 (Attachment) - First position
	- Point2 (Attachment) - Second position

	Attributes:
	- attr_studs_per_second: number (default: 1) - Movement speed
	- attr_wait_time: number (default: 1) - Time to wait at each end
]]

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "moving_platform"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_STUDS_PER_SECOND = 1
local DEFAULT_WAIT_TIME = 1

local MovingPlatform: CollectionWatcher.CollectionWatcher<Model | BasePart> = CollectionWatcher.new(COLLECTION_TAG)

local function getAlpha(cycleTime: number, duration: number)
	return TweenService:GetValue(cycleTime / duration, Enum.EasingStyle.Linear, Enum.EasingDirection.InOut)
end

MovingPlatform:RegisterHandler(function(object: CollectionWatcher.CollectionObject<Model | BasePart>)
	if not IS_CLIENT then
		return
	end

	local studsPerSecond = object.context:GetAttribute("attr_studs_per_second") :: number? or DEFAULT_STUDS_PER_SECOND
	local waitTime = object.context:GetAttribute("attr_wait_time") :: number? or DEFAULT_WAIT_TIME

	local point1: CFrame?
	local point2: CFrame?

	for _, child in object.context:GetChildren() do
		if child:IsA("Attachment") and child.Name == "Point1" then
			point1 = child.WorldCFrame
		elseif child:IsA("Attachment") and child.Name == "Point2" then
			point2 = child.WorldCFrame
		end
	end

	if not point1 or not point2 then
		warn("MovingPlatform requires Point1 and Point2 attachments")
		return
	end

	object.context:PivotTo(point1)

	local cycleTime = 0
	local distance = (point2.Position - point1.Position).Magnitude
	local moveTime = distance / studsPerSecond
	local totalCycleTime = (moveTime + waitTime) * 2

	local connection = RunService.Heartbeat:Connect(function(deltaTime)
		if not object.context or not object.context.Parent then
			return
		end

		cycleTime += deltaTime

		if cycleTime >= totalCycleTime then
			cycleTime = 0
		end

		local progress = cycleTime / totalCycleTime

		local movePhase1End = moveTime / totalCycleTime
		local waitPhase1End = (moveTime + waitTime) / totalCycleTime
		local movePhase2End = (moveTime + waitTime + moveTime) / totalCycleTime

		if progress < movePhase1End then
			local moveProgress = progress / movePhase1End
			local alpha = getAlpha(moveProgress * moveTime, moveTime)
			object.context:PivotTo(point1:Lerp(point2, alpha))
		elseif progress < waitPhase1End then
			object.context:PivotTo(point2)
		elseif progress < movePhase2End then
			local moveProgress = (progress - waitPhase1End) / (movePhase2End - waitPhase1End)
			local alpha = getAlpha(moveProgress * moveTime, moveTime)
			object.context:PivotTo(point2:Lerp(point1, alpha))
		else
			object.context:PivotTo(point1)
		end
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return MovingPlatform


--!strict
--[[
	@class Orbiter
	Orbits an object around a center point.

	Attributes:
	- attr_orbit_radius: number (default: 5) - Radius of the orbit in studs
	- attr_orbit_speed: number (default: 1) - Rotations per second
	- attr_center_name: string (optional) - Name of part to orbit around (defaults to original position)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "orbiter"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_ORBIT_RADIUS = 5
local DEFAULT_ORBIT_SPEED = 1

local Orbiter: CollectionWatcher.CollectionWatcher<Model | BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Orbiter:RegisterHandler(function(object: CollectionWatcher.CollectionObject<Model | BasePart>)
	if not IS_CLIENT then
		return
	end

	local orbitRadius = object.context:GetAttribute("attr_orbit_radius") :: number? or DEFAULT_ORBIT_RADIUS
	local orbitSpeed = object.context:GetAttribute("attr_orbit_speed") :: number? or DEFAULT_ORBIT_SPEED
	local centerName = object.context:GetAttribute("attr_center_name") :: string?

	local originalPivot = object.context:GetPivot()
	local centerPosition = originalPivot.Position

	if centerName then
		local centerPart = workspace:FindFirstChild(centerName, true)
		if centerPart and centerPart:IsA("BasePart") then
			centerPosition = centerPart.Position
		end
	end

	local elapsedTime = 0

	local connection = RunService.Heartbeat:Connect(function(deltaTime)
		if not object.context or not object.context.Parent then
			return
		end

		elapsedTime += deltaTime

		local angle = elapsedTime * orbitSpeed * math.pi * 2
		local offsetX = math.cos(angle) * orbitRadius
		local offsetZ = math.sin(angle) * orbitRadius

		local newPosition = centerPosition + Vector3.new(offsetX, 0, offsetZ)
		object.context:PivotTo(CFrame.new(newPosition) * originalPivot.Rotation)
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return Orbiter


--!strict
--[[
	@class Shaker
	Applies a vibration/jitter effect to an object.

	Attributes:
	- attr_intensity: number (default: 0.1) - Maximum shake offset in studs
	- attr_speed: number (default: 20) - Shake frequency
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "shaker"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_INTENSITY = 0.2
local DEFAULT_SPEED = 20

local Shaker: CollectionWatcher.CollectionWatcher<Model | BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Shaker:RegisterHandler(function(object: CollectionWatcher.CollectionObject<Model | BasePart>)
	if not IS_CLIENT then
		return
	end

	local intensity = object.context:GetAttribute("attr_intensity") :: number? or DEFAULT_INTENSITY
	local speed = object.context:GetAttribute("attr_speed") :: number? or DEFAULT_SPEED

	local originalPivot = object.context:GetPivot()
	local elapsedTime = 0
	local seed = math.random(1000)

	local connection = RunService.Heartbeat:Connect(function(deltaTime: number)
		if not object.context or not object.context.Parent then
			return
		end

		elapsedTime += deltaTime

		local noiseX = math.noise(elapsedTime * speed, seed, 0) * intensity
		local noiseY = math.noise(elapsedTime * speed, seed + 100, 0) * intensity
		local noiseZ = math.noise(elapsedTime * speed, seed + 200, 0) * intensity

		object.context:PivotTo(originalPivot + Vector3.new(noiseX, noiseY, noiseZ))
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return Shaker


--!strict
--[[
	@class Spinner
	Continuously rotates a Model or BasePart around a specified axis.

	Attributes:
	- attr_seconds_per_round: number (default: 2.5) - Time to complete a 360 degree rotation
	- attr_axis: Vector3 (default: 0, 1, 0) - Rotation axis (Y-axis by default)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "spinner"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_SECONDS_PER_ROUND = 2.5
local DEFAULT_AXIS = Vector3.new(0, 1, 0)

local Spinner: CollectionWatcher.CollectionWatcher<Model | BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Spinner:RegisterHandler(function(object: CollectionWatcher.CollectionObject<Model | BasePart>)
	if not IS_CLIENT then
		return
	end

	local secondsPerRound = object.context:GetAttribute("attr_seconds_per_round") :: number? or DEFAULT_SECONDS_PER_ROUND
	local axis = object.context:GetAttribute("attr_axis") :: Vector3? or DEFAULT_AXIS

	local originalPivot = object.context:GetPivot()
	local currentAngle = 0

	local connection = RunService.Heartbeat:Connect(function(deltaTime)
		if not object.context or not object.context.Parent then
			return
		end

		currentAngle += (360 / secondsPerRound) * deltaTime
		object.context:PivotTo(
			originalPivot
				* CFrame.Angles(
					axis.X * math.rad(currentAngle),
					axis.Y * math.rad(currentAngle),
					axis.Z * math.rad(currentAngle)
				)
		)
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return Spinner


--!strict
--[[
	@class SwingPart
	Swings a Model or BasePart like a pendulum.

	Make sure to edit the part's "PivotOffset" so it swings from that pivot.

	Attributes:
	- attr_seconds_per_cycle: number (default: 2.5) - Time for a full swing cycle
	- attr_swing_angle: number (default: 30) - Maximum swing angle in degrees
	- attr_axis: Vector3 (default: 0, 0, 1) - Swing axis (Z-axis by default)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "swing_part"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_SECONDS_PER_CYCLE = 2.5
local DEFAULT_SWING_ANGLE = 30
local DEFAULT_AXIS = Vector3.new(0, 0, 1)

local SwingPart: CollectionWatcher.CollectionWatcher<Model | BasePart> = CollectionWatcher.new(COLLECTION_TAG)

SwingPart:RegisterHandler(function(object: CollectionWatcher.CollectionObject<Model | BasePart>)
	if not IS_CLIENT then
		return
	end

	local secondsPerCycle = object.context:GetAttribute("attr_seconds_per_cycle") :: number? or DEFAULT_SECONDS_PER_CYCLE
	local swingAngle = object.context:GetAttribute("attr_swing_angle") :: number? or DEFAULT_SWING_ANGLE
	local axis = object.context:GetAttribute("attr_axis") :: Vector3? or DEFAULT_AXIS
	local originalPivot = object.context:GetPivot()
	local cycleTime = 0

	local connection = RunService.Heartbeat:Connect(function(deltaTime)
		if not object.context or not object.context.Parent then
			return
		end

		cycleTime += deltaTime

		while cycleTime >= secondsPerCycle do
			cycleTime -= secondsPerCycle
		end

		local swingProgress = math.sin((cycleTime / secondsPerCycle) * math.pi * 2)
		local currentAngle = swingProgress * swingAngle

		object.context:PivotTo(
			originalPivot
				* CFrame.Angles(
					axis.X * math.rad(currentAngle),
					axis.Y * math.rad(currentAngle),
					axis.Z * math.rad(currentAngle)
				)
		)
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return SwingPart


--!strict
--[[
	@class Thwomp
	Smash/crusher behavior that drops down and rises back up.

	Requires:
	- Floor (Attachment) - The target position when dropped

	Attributes:
	- attr_animation_time: number (default: 1) - Time to animate up/down
	- attr_time_on_floor: number (default: 1) - Time to stay at floor position
	- attr_time_between_drops: number (default: 1) - Wait time before dropping again
]]

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "thwomp"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_ANIMATION_TIME = 1
local DEFAULT_TIME_ON_FLOOR = 1
local DEFAULT_TIME_BETWEEN_DROPS = 1

local Thwomp: CollectionWatcher.CollectionWatcher<Model | BasePart> = CollectionWatcher.new(COLLECTION_TAG)

local function getAlpha(cycleTime: number, duration: number, animatingIn: boolean)
	return TweenService:GetValue(
		cycleTime / duration,
		Enum.EasingStyle.Back,
		animatingIn and Enum.EasingDirection.In or Enum.EasingDirection.Out
	)
end

Thwomp:RegisterHandler(function(object: CollectionWatcher.CollectionObject<Model | BasePart>)
	if not IS_CLIENT then
		return
	end

	local animationTime = object.context:GetAttribute("attr_animation_time") :: number? or DEFAULT_ANIMATION_TIME
	local timeOnFloor = object.context:GetAttribute("attr_time_on_floor") :: number? or DEFAULT_TIME_ON_FLOOR
	local timeBetweenDrops = object.context:GetAttribute("attr_time_between_drops") :: number? or DEFAULT_TIME_BETWEEN_DROPS

	local floorAttachment: Attachment?

	for _, child in object.context:GetChildren() do
		if child:IsA("Attachment") and child.Name == "Floor" then
			floorAttachment = child
			break
		end
	end

	if not floorAttachment then
		warn("Thwomp requires a Floor attachment")
		return
	end

	local originalPivot = object.context:GetPivot()
	local floorPosition = floorAttachment.WorldCFrame

	local cycleTime = 0
	local totalCycleTime = animationTime + timeOnFloor + animationTime + timeBetweenDrops

	local connection = RunService.Heartbeat:Connect(function(deltaTime)
		if not object.context or not object.context.Parent then
			return
		end

		cycleTime += deltaTime

		if cycleTime >= totalCycleTime then
			cycleTime = 0
		end

		local progress = cycleTime / totalCycleTime

		local dropPhaseEnd = animationTime / totalCycleTime
		local floorPhaseEnd = (animationTime + timeOnFloor) / totalCycleTime
		local risePhaseEnd = (animationTime + timeOnFloor + animationTime) / totalCycleTime

		if progress < dropPhaseEnd then
			local dropProgress = progress / dropPhaseEnd
			local alpha = getAlpha(dropProgress * animationTime, animationTime, true)
			object.context:PivotTo(originalPivot:Lerp(floorPosition, alpha))
		elseif progress < floorPhaseEnd then
			object.context:PivotTo(floorPosition)
		elseif progress < risePhaseEnd then
			local riseProgress = (progress - floorPhaseEnd) / (risePhaseEnd - floorPhaseEnd)
			local alpha = getAlpha(riseProgress * animationTime, animationTime, false)
			object.context:PivotTo(floorPosition:Lerp(originalPivot, alpha))
		else
			object.context:PivotTo(originalPivot)
		end
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return Thwomp


--!strict
--[[
	@class KillPart
	Damages or kills players on touch.

	Attributes:
	- attr_damage: number (default: 100) - Amount of damage to deal
	- attr_cooldown: number (default: 3) - Cooldown between damage applications per player
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "kill_part"
local IS_SERVER = RunService:IsServer()
local DEFAULT_DAMAGE = 100
local DEFAULT_COOLDOWN = 3

local KillPart: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

KillPart:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local damage = object.context:GetAttribute("attr_damage") :: number? or DEFAULT_DAMAGE
	local cooldown = object.context:GetAttribute("attr_cooldown") :: number? or DEFAULT_COOLDOWN

	local cooldowns: { Player } = {}

	local connection = Util.onPlayerTouch(object.context, function(player: Player)
		if table.find(cooldowns, player) then
			return
		end

		table.insert(cooldowns, player)

		task.delay(cooldown, function()
			local index = table.find(cooldowns, player)

			if index then
				table.remove(cooldowns, index)
			end
		end)

		local humanoid = Util.getHumanoid(player)

		if humanoid then
			humanoid:TakeDamage(damage)
		end
	end)

	object.Destroyed:Once(function()
		if connection then
			connection:Disconnect()
		end
	end)
end)

return KillPart


--!strict
--[[
	@class DamageZone
	Continuously damages players while they remain inside the zone.

	Attributes:
	- attr_damage_rate: number (default: 1) - Time between damage ticks in seconds
	- attr_damage_amount: number (default: 10) - Damage dealt per tick
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "damage_zone"
local IS_SERVER = RunService:IsServer()
local DEFAULT_DAMAGE_RATE = 1
local DEFAULT_DAMAGE_AMOUNT = 10

local DamageZone: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

DamageZone:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local damageRate = object.context:GetAttribute("attr_damage_rate") :: number? or DEFAULT_DAMAGE_RATE
	local damageAmount = object.context:GetAttribute("attr_damage_amount") :: number? or DEFAULT_DAMAGE_AMOUNT

	local playersInZone: { [Player]: boolean } = {}
	local damageThread: thread?

	local function startDamageLoop()
		if damageThread then
			return
		end

		damageThread = task.spawn(function()
			while true do
				task.wait(damageRate)

				for player in playersInZone do
					local humanoid = Util.getHumanoid(player)
					if humanoid and humanoid.Health > 0 then
						humanoid:TakeDamage(damageAmount)
					end
				end

				if next(playersInZone) == nil then
					damageThread = nil
					break
				end
			end
		end)
	end

	local touchConnection = object.context.Touched:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if player and not playersInZone[player] then
			playersInZone[player] = true
			startDamageLoop()
		end
	end)

	local touchEndedConnection = object.context.TouchEnded:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if player then
			playersInZone[player] = nil
		end
	end)

	object.Destroyed:Once(function()
		touchConnection:Disconnect()
		touchEndedConnection:Disconnect()
		if damageThread then
			task.cancel(damageThread)
		end
	end)
end)

return DamageZone


--!strict
--[[
	@class HealPart
	Heals players on touch.

	Attributes:
	- attr_heal_amount: number (default: 25) - Amount of health to restore
	- attr_cooldown: number (default: 3) - Cooldown between heals per player
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "heal_part"
local IS_SERVER = RunService:IsServer()
local DEFAULT_HEAL_AMOUNT = 25
local DEFAULT_COOLDOWN = 3

local HealPart: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

HealPart:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local healAmount = object.context:GetAttribute("attr_heal_amount") :: number? or DEFAULT_HEAL_AMOUNT
	local cooldown = object.context:GetAttribute("attr_cooldown") :: number? or DEFAULT_COOLDOWN

	local cooldowns: { Player } = {}

	local connection = Util.onPlayerTouch(object.context, function(player: Player)
		if table.find(cooldowns, player) then
			return
		end

		table.insert(cooldowns, player)

		task.delay(cooldown, function()
			local index = table.find(cooldowns, player)

			if index then
				table.remove(cooldowns, index)
			end
		end)

		local humanoid = Util.getHumanoid(player)

		if humanoid then
			humanoid.Health = math.min(humanoid.Health + healAmount, humanoid.MaxHealth)
		end
	end)

	object.Destroyed:Once(function()
		if connection then
			connection:Disconnect()
		end
	end)
end)

return HealPart


--!strict
--[[
	@class HealZone
	Continuously heals players while they remain inside the zone.

	Attributes:
	- attr_heal_rate: number (default: 1) - Time between heal ticks in seconds
	- attr_heal_amount: number (default: 5) - Health restored per tick
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "heal_zone"
local IS_SERVER = RunService:IsServer()
local DEFAULT_HEAL_RATE = 1
local DEFAULT_HEAL_AMOUNT = 5

local HealZone: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

HealZone:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local healRate = object.context:GetAttribute("attr_heal_rate") :: number? or DEFAULT_HEAL_RATE
	local healAmount = object.context:GetAttribute("attr_heal_amount") :: number? or DEFAULT_HEAL_AMOUNT

	local playersInZone: { [Player]: boolean } = {}
	local healThread: thread?

	local function startHealLoop()
		if healThread then
			return
		end

		healThread = task.spawn(function()
			while true do
				task.wait(healRate)

				for player in playersInZone do
					local humanoid = Util.getHumanoid(player)
					if humanoid and humanoid.Health > 0 then
						humanoid.Health = math.min(humanoid.Health + healAmount, humanoid.MaxHealth)
					end
				end

				if next(playersInZone) == nil then
					healThread = nil
					break
				end
			end
		end)
	end

	local touchConnection = object.context.Touched:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if player and not playersInZone[player] then
			playersInZone[player] = true
			startHealLoop()
		end
	end)

	local touchEndedConnection = object.context.TouchEnded:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if player then
			playersInZone[player] = nil
		end
	end)

	object.Destroyed:Once(function()
		touchConnection:Disconnect()
		touchEndedConnection:Disconnect()
		if healThread then
			task.cancel(healThread)
		end
	end)
end)

return HealZone


--!strict
--[[
	@class HingePart
	Creates a hinge constraint on this part.

	Attributes:
	- attr_target_name: string (required) - Name of the target part to hinge to
	- attr_hinge_axis: Vector3 (default: 0, 1, 0) - Axis of rotation
	- attr_motor_enabled: boolean (default: false) - Enable motor rotation
	- attr_motor_speed: number (default: 45) - Motor rotation speed in degrees/second
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "hinge_part"
local IS_SERVER = RunService:IsServer()
local DEFAULT_HINGE_AXIS = Vector3.new(0, 1, 0)
local DEFAULT_MOTOR_ENABLED = false
local DEFAULT_MOTOR_SPEED = 45

local HingePart: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

HingePart:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local targetName = object.context:GetAttribute("attr_target_name") :: string?
	local hingeAxis = object.context:GetAttribute("attr_hinge_axis") :: Vector3? or DEFAULT_HINGE_AXIS
	local motorEnabled = object.context:GetAttribute("attr_motor_enabled") :: boolean? or DEFAULT_MOTOR_ENABLED
	local motorSpeed = object.context:GetAttribute("attr_motor_speed") :: number? or DEFAULT_MOTOR_SPEED

	if not targetName then
		warn("HingePart requires attr_target_name attribute")
		return
	end

	local target = workspace:FindFirstChild(targetName, true)
	if not target or not target:IsA("BasePart") then
		warn(`HingePart target "{targetName}" not found`)
		return
	end

	local attachment0 = Instance.new("Attachment")
	attachment0.Axis = hingeAxis
	attachment0.Parent = object.context

	local attachment1 = Instance.new("Attachment")
	attachment1.Axis = hingeAxis
	attachment1.Parent = target

	local hinge = Instance.new("HingeConstraint")
	hinge.Attachment0 = attachment0
	hinge.Attachment1 = attachment1

	if motorEnabled then
		hinge.ActuatorType = Enum.ActuatorType.Motor
		hinge.AngularVelocity = math.rad(motorSpeed)
		hinge.MotorMaxTorque = math.huge
	end

	hinge.Parent = object.context

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_motor_enabled" then
			local enabled = object.context:GetAttribute("attr_motor_enabled") :: boolean? or DEFAULT_MOTOR_ENABLED
			if enabled then
				hinge.ActuatorType = Enum.ActuatorType.Motor
				hinge.MotorMaxTorque = math.huge
			else
				hinge.ActuatorType = Enum.ActuatorType.None
			end
		elseif attributeName == "attr_motor_speed" then
			hinge.AngularVelocity = math.rad(
				object.context:GetAttribute("attr_motor_speed") :: number? or DEFAULT_MOTOR_SPEED
			)
		end
	end)

	object.Destroyed:Once(function()
		attributeConnection:Disconnect()
		hinge:Destroy()
		attachment0:Destroy()
		attachment1:Destroy()
	end)
end)

return HingePart


--!strict
--[[
	@class RopePart
	Creates a rope constraint between this part and a target.

	Attributes:
	- attr_target_name: string (required) - Name of the target part
	- attr_length: number (default: 10) - Rope length in studs
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "rope_part"
local IS_SERVER = RunService:IsServer()
local DEFAULT_LENGTH = 10

local RopePart: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

RopePart:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local targetName = object.context:GetAttribute("attr_target_name") :: string?
	local length = object.context:GetAttribute("attr_length") :: number? or DEFAULT_LENGTH

	if not targetName then
		warn("RopePart requires attr_target_name attribute")
		return
	end

	local target = workspace:FindFirstChild(targetName, true)
	if not target or not target:IsA("BasePart") then
		warn(`RopePart target "{targetName}" not found`)
		return
	end

	local attachment0 = Instance.new("Attachment")
	attachment0.Parent = object.context

	local attachment1 = Instance.new("Attachment")
	attachment1.Parent = target

	local rope = Instance.new("RopeConstraint")
	rope.Attachment0 = attachment0
	rope.Attachment1 = attachment1
	rope.Length = length
	rope.Visible = true
	rope.Parent = object.context

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_length" then
			rope.Length = object.context:GetAttribute("attr_length") :: number? or DEFAULT_LENGTH
		end
	end)

	object.Destroyed:Once(function()
		attributeConnection:Disconnect()
		rope:Destroy()
		attachment0:Destroy()
		attachment1:Destroy()
	end)
end)

return RopePart


--!strict
--[[
	@class AutoDoor
	Auto-opening door that slides open when a player approaches.

	Attributes:
	- attr_open_offset: Vector3 (default: 0, 5, 0) - How far the door moves when open
	- attr_open_time: number (default: 0.5) - Time to open/close
	- attr_trigger_distance: number (default: 10) - Distance to trigger opening
]]

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local Players = game:GetService("Players")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "auto_door"
local IS_CLIENT = RunService:IsClient()
local LOCAL_PLAYER = Players.LocalPlayer
local DEFAULT_OPEN_OFFSET = Vector3.new(0, 5, 0)
local DEFAULT_OPEN_TIME = 0.5
local DEFAULT_TRIGGER_DISTANCE = 10

local AutoDoor: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

AutoDoor:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local openOffset = object.context:GetAttribute("attr_open_offset") :: Vector3? or DEFAULT_OPEN_OFFSET
	local openTime = object.context:GetAttribute("attr_open_time") :: number? or DEFAULT_OPEN_TIME
	local triggerDistance = object.context:GetAttribute("attr_trigger_distance") :: number? or DEFAULT_TRIGGER_DISTANCE

	local closedCFrame = object.context.CFrame
	local openCFrame = closedCFrame + openOffset
	local isOpen = false
	local currentTween: Tween?

	local tweenInfo = TweenInfo.new(openTime, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut)

	local function openDoor()
		if isOpen then
			return
		end
		isOpen = true

		if currentTween then
			currentTween:Cancel()
		end

		currentTween = TweenService:Create(object.context, tweenInfo, { CFrame = openCFrame })

		if currentTween then
			currentTween:Play()
		end
	end

	local function closeDoor()
		if not isOpen then
			return
		end
		isOpen = false

		if currentTween then
			currentTween:Cancel()
		end

		currentTween = TweenService:Create(object.context, tweenInfo, { CFrame = closedCFrame })

		if currentTween then
			currentTween:Play()
		end
	end

	local triggerDistanceSq = triggerDistance * triggerDistance

	local connection = RunService.Heartbeat:Connect(function()
		if not object.context or not object.context.Parent then
			return
		end

		local character = LOCAL_PLAYER and LOCAL_PLAYER.Character
		if not character then
			closeDoor()
			return
		end

		local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
		if not rootPart then
			closeDoor()
			return
		end

		local offset = rootPart.Position - object.context.Position
		local distanceSq = offset.X * offset.X + offset.Y * offset.Y + offset.Z * offset.Z

		if distanceSq <= triggerDistanceSq then
			openDoor()
		else
			closeDoor()
		end
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
		if currentTween then
			currentTween:Cancel()
		end
	end)
end)

return AutoDoor


--!strict
--[[
	@class SlidingPanel
	Panel that slides open when triggered.

	Attributes:
	- attr_slide_offset: Vector3 (default: 5, 0, 0) - How far the panel slides
	- attr_slide_time: number (default: 0.5) - Time to slide open/close
	- attr_trigger_distance: number (default: 10) - Distance to trigger
]]

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local Players = game:GetService("Players")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "sliding_panel"
local IS_CLIENT = RunService:IsClient()
local LOCAL_PLAYER = Players.LocalPlayer
local DEFAULT_SLIDE_OFFSET = Vector3.new(5, 0, 0)
local DEFAULT_SLIDE_TIME = 0.5
local DEFAULT_TRIGGER_DISTANCE = 10

local SlidingPanel: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

SlidingPanel:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local slideOffset = object.context:GetAttribute("attr_slide_offset") :: Vector3? or DEFAULT_SLIDE_OFFSET
	local slideTime = object.context:GetAttribute("attr_slide_time") :: number? or DEFAULT_SLIDE_TIME
	local triggerDistance = object.context:GetAttribute("attr_trigger_distance") :: number? or DEFAULT_TRIGGER_DISTANCE

	local closedCFrame = object.context.CFrame
	local openCFrame = closedCFrame + object.context.CFrame:VectorToWorldSpace(slideOffset)
	local isOpen = false
	local currentTween: Tween?

	local tweenInfo = TweenInfo.new(slideTime, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut)

	local function openPanel()
		if isOpen then
			return
		end
		isOpen = true

		if currentTween then
			currentTween:Cancel()
		end

		currentTween = TweenService:Create(object.context, tweenInfo, { CFrame = openCFrame })

		if currentTween then
			currentTween:Play()
		end
	end

	local function closePanel()
		if not isOpen then
			return
		end
		isOpen = false

		if currentTween then
			currentTween:Cancel()
		end

		currentTween = TweenService:Create(object.context, tweenInfo, { CFrame = closedCFrame })

		if currentTween then
			currentTween:Play()
		end
	end

	local triggerDistanceSq = triggerDistance * triggerDistance

	local connection = RunService.Heartbeat:Connect(function()
		if not object.context or not object.context.Parent then
			return
		end

		local character = LOCAL_PLAYER and LOCAL_PLAYER.Character
		if not character then
			closePanel()
			return
		end

		local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
		if not rootPart then
			closePanel()
			return
		end

		local offset = rootPart.Position - object.context.Position
		local distanceSq = offset.X * offset.X + offset.Y * offset.Y + offset.Z * offset.Z

		if distanceSq <= triggerDistanceSq then
			openPanel()
		else
			closePanel()
		end
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
		if currentTween then
			currentTween:Cancel()
		end
	end)
end)

return SlidingPanel


--!strict
--[[
	@class SwingGate
	Rotating gate that swings open when triggered.

	Make sure to edit the part's "PivotOffset" so it swings from that pivot.

	Attributes:
	- attr_open_angle: number (default: 90) - Angle to rotate when open
	- attr_open_time: number (default: 0.5) - Time to open/close
	- attr_trigger_distance: number (default: 10) - Distance to trigger opening
	- attr_axis: Vector3 (default: 0, 1, 0) - Rotation axis
]]

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

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "swing_gate"
local IS_CLIENT = RunService:IsClient()
local LOCAL_PLAYER = Players.LocalPlayer
local DEFAULT_OPEN_ANGLE = 90
local DEFAULT_OPEN_TIME = 0.5
local DEFAULT_TRIGGER_DISTANCE = 10
local DEFAULT_AXIS = Vector3.yAxis

local SwingGate: CollectionWatcher.CollectionWatcher<Model | BasePart> = CollectionWatcher.new(COLLECTION_TAG)

SwingGate:RegisterHandler(function(object: CollectionWatcher.CollectionObject<Model | BasePart>)
	if not IS_CLIENT then
		return
	end

	local openAngle = object.context:GetAttribute("attr_open_angle") :: number? or DEFAULT_OPEN_ANGLE
	local openTime = object.context:GetAttribute("attr_open_time") :: number? or DEFAULT_OPEN_TIME
	local triggerDistance = object.context:GetAttribute("attr_trigger_distance") :: number? or DEFAULT_TRIGGER_DISTANCE
	local axis = object.context:GetAttribute("attr_axis") :: Vector3? or DEFAULT_AXIS

	local closedPivot = object.context:GetPivot()

	local currentAngle = 0
	local targetAngle = 0

	local triggerDistanceSq = triggerDistance * triggerDistance

	local connection = RunService.Heartbeat:Connect(function(deltaTime)
		if not object.context or not object.context.Parent then
			return
		end

		local character = LOCAL_PLAYER and LOCAL_PLAYER.Character
		local shouldBeOpen = false

		if character then
			local rootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
			if rootPart then
				local gatePosition = object.context:GetPivot().Position
				local offset = rootPart.Position - gatePosition
				local distanceSq = offset.X * offset.X + offset.Y * offset.Y + offset.Z * offset.Z
				shouldBeOpen = distanceSq <= triggerDistanceSq
			end
		end

		targetAngle = shouldBeOpen and openAngle or 0

		local angleDiff = targetAngle - currentAngle
		local rotationSpeed = openAngle / openTime

		if math.abs(angleDiff) > 0.1 then
			local angleChange = math.sign(angleDiff) * rotationSpeed * deltaTime
			if math.abs(angleChange) > math.abs(angleDiff) then
				angleChange = angleDiff
			end
			currentAngle += angleChange

			object.context:PivotTo(
				closedPivot
					* CFrame.Angles(
						axis.X * math.rad(currentAngle),
						axis.Y * math.rad(currentAngle),
						axis.Z * math.rad(currentAngle)
					)
			)
		end
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return SwingGate


--!strict
--[[
	@class Button
	Touch-activated button with optional cooldown.

	Attributes:
	- attr_cooldown: number (default: 1) - Cooldown between activations

	Fires Signal "ButtonPressed" with (player, part)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Signal = require(Packages.Signal)
local Util = require(Packages.Util)

local COLLECTION_TAG = "button"
local IS_SERVER = RunService:IsServer()
local DEFAULT_COOLDOWN = 1

local ButtonPressed = Signal.new()

local Button: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Button:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local cooldown = object.context:GetAttribute("attr_cooldown") :: number? or DEFAULT_COOLDOWN

	local isOnCooldown = false

	local connection = Util.onPlayerTouch(object.context, function(player: Player)
		if isOnCooldown then
			return
		end

		isOnCooldown = true
		ButtonPressed:Fire(player, object.context)

		task.delay(cooldown, function()
			isOnCooldown = false
		end)
	end)

	object.Destroyed:Once(function()
		if connection then
			connection:Disconnect()
		end
	end)
end)

local module = {
	watcher = Button,
	ButtonPressed = ButtonPressed,
}

return module


--!strict
--[[
	@class Clickable
	Adds a ClickDetector to a part.

	Attributes:
	- attr_max_distance: number (default: 32) - Maximum click distance

	Fires Signal "Clicked" with (player, part)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Signal = require(Packages.Signal)

local COLLECTION_TAG = "clickable"
local IS_SERVER = RunService:IsServer()
local DEFAULT_MAX_DISTANCE = 32

local Clicked = Signal.new()

local Clickable: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Clickable:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local maxDistance = object.context:GetAttribute("attr_max_distance") :: number? or DEFAULT_MAX_DISTANCE

	local clickDetector = Instance.new("ClickDetector")
	clickDetector.MaxActivationDistance = maxDistance
	clickDetector.Parent = object.context

	local clickConnection = clickDetector.MouseClick:Connect(function(player: Player)
		Clicked:Fire(player, object.context)
	end)

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_max_distance" then
			clickDetector.MaxActivationDistance = object.context:GetAttribute("attr_max_distance") :: number?
				or DEFAULT_MAX_DISTANCE
		end
	end)

	object.Destroyed:Once(function()
		clickConnection:Disconnect()
		attributeConnection:Disconnect()
		clickDetector:Destroy()
	end)
end)

local module = {
	watcher = Clickable,
	Clicked = Clicked,
}

return module


--!strict
--[[
	@class PromptPart
	Adds a proximity prompt to a part.

	Attributes:
	- attr_prompt_text: string (default: "Interact") - Text shown on the prompt
	- attr_action_text: string (default: "Prompt") - Action key text
	- attr_hold_duration: number (default: 0) - Hold duration in seconds
	- attr_max_distance: number (default: 10) - Maximum activation distance
	- attr_destroy_on_trigger: boolean (default: false) - Destroy part after activation

	Fires Signal "PromptTriggered" with (player, part)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Signal = require(Packages.Signal)

local COLLECTION_TAG = "prompt_part"
local IS_SERVER = RunService:IsServer()
local DEFAULT_PROMPT_TEXT = "Interact"
local DEFAULT_ACTION_TEXT = "Prompt"
local DEFAULT_HOLD_DURATION = 0
local DEFAULT_MAX_DISTANCE = 10
local DEFAULT_DESTROY_ON_TRIGGER = false

local PromptTriggered = Signal.new()

local PromptPart: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

PromptPart:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local promptText = object.context:GetAttribute("attr_prompt_text") :: string? or DEFAULT_PROMPT_TEXT
	local actionText = object.context:GetAttribute("attr_action_text") :: string? or DEFAULT_ACTION_TEXT
	local holdDuration = object.context:GetAttribute("attr_hold_duration") :: number? or DEFAULT_HOLD_DURATION
	local maxDistance = object.context:GetAttribute("attr_max_distance") :: number? or DEFAULT_MAX_DISTANCE
	local destroyOnTrigger = object.context:GetAttribute("attr_destroy_on_trigger") :: boolean? or DEFAULT_DESTROY_ON_TRIGGER

	local prompt = Instance.new("ProximityPrompt")
	prompt.ObjectText = promptText
	prompt.ActionText = actionText
	prompt.HoldDuration = holdDuration
	prompt.MaxActivationDistance = maxDistance
	prompt.Parent = object.context

	local triggerConnection = prompt.Triggered:Connect(function(player: Player)
		PromptTriggered:Fire(player, object.context)

		if destroyOnTrigger then
			object.context:Destroy()
		end
	end)

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_prompt_text" then
			prompt.ObjectText = object.context:GetAttribute("attr_prompt_text") :: string? or DEFAULT_PROMPT_TEXT
		elseif attributeName == "attr_action_text" then
			prompt.ActionText = object.context:GetAttribute("attr_action_text") :: string? or DEFAULT_ACTION_TEXT
		elseif attributeName == "attr_hold_duration" then
			prompt.HoldDuration = object.context:GetAttribute("attr_hold_duration") :: number? or DEFAULT_HOLD_DURATION
		elseif attributeName == "attr_max_distance" then
			prompt.MaxActivationDistance = object.context:GetAttribute("attr_max_distance") :: number? or DEFAULT_MAX_DISTANCE
		end
	end)

	object.Destroyed:Once(function()
		triggerConnection:Disconnect()
		attributeConnection:Disconnect()
		prompt:Destroy()
	end)
end)

local module = {
	watcher = PromptPart,
	PromptTriggered = PromptTriggered,
}

return module


--!strict
--[[
	@class Conveyor
	Moves players and parts along the conveyor direction.

	Attributes:
	- attr_speed: number (default: 10) - Conveyor speed in studs per second
	- attr_direction: Vector3 (default: 0, 0, -1) - Direction relative to part's CFrame
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "conveyor"
local IS_SERVER = RunService:IsServer()
local DEFAULT_SPEED = 10
local DEFAULT_DIRECTION = Vector3.new(0, 0, -1)

local Conveyor: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Conveyor:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local speed = object.context:GetAttribute("attr_speed") :: number? or DEFAULT_SPEED
	local direction = object.context:GetAttribute("attr_direction") :: Vector3? or DEFAULT_DIRECTION

	local worldDirection = object.context.CFrame:VectorToWorldSpace(direction.Unit)
	object.context.AssemblyLinearVelocity = worldDirection * speed

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_speed" then
			speed = object.context:GetAttribute("attr_speed") :: number? or DEFAULT_SPEED
			object.context.AssemblyLinearVelocity = worldDirection * speed
		elseif attributeName == "attr_direction" then
			direction = object.context:GetAttribute("attr_direction") :: Vector3? or DEFAULT_DIRECTION
			worldDirection = object.context.CFrame:VectorToWorldSpace(direction.Unit)
			object.context.AssemblyLinearVelocity = worldDirection * speed
		end
	end)

	object.Destroyed:Once(function()
		attributeConnection:Disconnect()
	end)
end)

return Conveyor


--!strict
--[[
	@class GravityZone
	Applies custom gravity to players while inside the zone.

	Attributes:
	- attr_gravity_force: number (default: 50) - Gravity force (positive = down, negative = up)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "gravity_zone"
local IS_SERVER = RunService:IsServer()
local DEFAULT_GRAVITY_FORCE = 50

local GravityZone: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

GravityZone:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local gravityForce = object.context:GetAttribute("attr_gravity_force") :: number? or DEFAULT_GRAVITY_FORCE

	local playersInZone: { [Player]: BodyForce } = {}

	local touchConnection = object.context.Touched:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if not player or playersInZone[player] then
			return
		end

		local character = player.Character
		if not character then
			return
		end

		local rootPart = character:FindFirstChild("HumanoidRootPart")
		if not rootPart or not rootPart:IsA("BasePart") then
			return
		end

		local mass = rootPart.AssemblyMass
		local defaultGravity = workspace.Gravity
		local counterForce = mass * defaultGravity
		local customGravityForce = mass * gravityForce

		local bodyForce = Instance.new("BodyForce")
		bodyForce.Force = Vector3.new(0, counterForce - customGravityForce, 0)
		bodyForce.Parent = rootPart

		playersInZone[player] = bodyForce
	end)

	local touchEndedConnection = object.context.TouchEnded:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if not player or not playersInZone[player] then
			return
		end

		playersInZone[player]:Destroy()
		playersInZone[player] = nil
	end)

	object.Destroyed:Once(function()
		touchConnection:Disconnect()
		touchEndedConnection:Disconnect()

		for _, bodyForce in playersInZone do
			bodyForce:Destroy()
		end
	end)
end)

return GravityZone


--!strict
--[[
	@class JumpPad
	Launches players into the air on touch.

	Attributes:
	- attr_jump_distance: number (default: 20) - Target studs distance to launch
	- attr_cooldown: number (default: 1) - Cooldown between launches per player
	- attr_push_relative_to_object: boolean (default: true) - Push relative to object's up vector
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "jump_pad"
local IS_SERVER = RunService:IsServer()
local DEFAULT_COOLDOWN = 1
local DEFAULT_JUMP_DISTANCE = 20
local DEFAULT_PUSH_RELATIVE_TO_OBJECT = true

local JumpPad: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

JumpPad:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local cooldown = object.context:GetAttribute("attr_cooldown") :: number? or DEFAULT_COOLDOWN
	local jumpDistance = object.context:GetAttribute("attr_jump_distance") :: number? or DEFAULT_JUMP_DISTANCE
	local pushRelativeToObject = object.context:GetAttribute("attr_push_relative_to_object") :: boolean?
	if pushRelativeToObject == nil then
		pushRelativeToObject = DEFAULT_PUSH_RELATIVE_TO_OBJECT
	end

	local pushDirection = pushRelativeToObject and object.context.CFrame.UpVector or Vector3.new(0, 1, 0)

	local cooldowns: { Player } = {}

	local connection = Util.onPlayerTouch(object.context, function(player: Player)
		if table.find(cooldowns, player) then
			return
		end

		table.insert(cooldowns, player)

		task.delay(cooldown, function()
			local index = table.find(cooldowns, player)

			if index then
				table.remove(cooldowns, index)
			end
		end)

		local character = player.Character

		if not character then
			return
		end

		local rootPart = character:FindFirstChild("HumanoidRootPart")
		if not rootPart or not rootPart:IsA("BasePart") then
			return
		end

		local attachment = Instance.new("Attachment")
		attachment.Parent = rootPart

		local mass = rootPart.AssemblyMass
		local forceMagnitude = jumpDistance * mass * 30
		local force = pushDirection * forceMagnitude

		local vectorForce = Instance.new("VectorForce")
		vectorForce.Force = force
		vectorForce.RelativeTo = Enum.ActuatorRelativeTo.World
		vectorForce.ApplyAtCenterOfMass = true
		vectorForce.Attachment0 = attachment
		vectorForce.Parent = rootPart

		local duration = 0.5
		local initialForce = force
		local elapsed = 0
		local distanceTravelled = 0

		local originalPosition = rootPart.Position

		local heartbeatConnection: RBXScriptConnection
		heartbeatConnection = RunService.Heartbeat:Connect(function(deltaTime: number)
			distanceTravelled = (rootPart.Position - originalPosition).Magnitude
			elapsed += deltaTime

			local progress = elapsed / duration

			if distanceTravelled >= jumpDistance or progress >= 1 then
				vectorForce:Destroy()
				attachment:Destroy()
				heartbeatConnection:Disconnect()
			else
				local forceMultiplier = 1 - (progress * progress)
				vectorForce.Force = initialForce * forceMultiplier
			end
		end)
	end)

	object.Destroyed:Once(function()
		if connection then
			connection:Disconnect()
		end
	end)
end)

return JumpPad


--!strict
--[[
	@class PushZone
	Pushes players in a specified direction while inside the zone.

	Attributes:
	- attr_force: number (default: 20) - Push force magnitude
	- attr_direction: Vector3 (default: 0, 0, 1) - Push direction relative to part's CFrame
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "push_zone"
local IS_SERVER = RunService:IsServer()
local DEFAULT_FORCE = 20
local DEFAULT_DIRECTION = Vector3.new(0, 0, 1)

local PushZone: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

PushZone:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local force = object.context:GetAttribute("attr_force") :: number? or DEFAULT_FORCE
	local direction = object.context:GetAttribute("attr_direction") :: Vector3? or DEFAULT_DIRECTION

	local playersInZone: { [Player]: boolean } = {}
	local pushThread: thread?

	local function startPushLoop()
		if pushThread then
			return
		end

		pushThread = task.spawn(function()
			while true do
				local worldDirection = object.context.CFrame:VectorToWorldSpace(direction.Unit)

				for player in playersInZone do
					local character = player.Character
					if character then
						local rootPart = character:FindFirstChild("HumanoidRootPart")
						if rootPart and rootPart:IsA("BasePart") then
							rootPart.AssemblyLinearVelocity = rootPart.AssemblyLinearVelocity + worldDirection * rootPart.AssemblyMass * force * 0.1
						end
					end
				end

				if next(playersInZone) == nil then
					pushThread = nil
					break
				end

				RunService.Heartbeat:Wait()
			end
		end)
	end

	local touchConnection = object.context.Touched:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if player and not playersInZone[player] then
			playersInZone[player] = true
			startPushLoop()
		end
	end)

	local touchEndedConnection = object.context.TouchEnded:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if player then
			playersInZone[player] = nil
		end
	end)

	object.Destroyed:Once(function()
		touchConnection:Disconnect()
		touchEndedConnection:Disconnect()
		if pushThread then
			task.cancel(pushThread)
		end
	end)
end)

return PushZone


--!strict
--[[
	@class SlowZone
	Slows player movement while inside the zone.

	Attributes:
	- attr_multiplier: number (default: 0.5) - Speed multiplier (0.5 = 50% speed)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "slow_zone"
local IS_SERVER = RunService:IsServer()
local DEFAULT_MULTIPLIER = 0.5

local SlowZone: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

SlowZone:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local multiplier = object.context:GetAttribute("attr_multiplier") :: number? or DEFAULT_MULTIPLIER

	local playersInZone: { [Player]: number } = {}

	local touchConnection = object.context.Touched:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if not player or playersInZone[player] then
			return
		end

		local humanoid = Util.getHumanoid(player)
		if not humanoid then
			return
		end

		playersInZone[player] = humanoid.WalkSpeed
		humanoid.WalkSpeed = humanoid.WalkSpeed * multiplier
	end)

	local touchEndedConnection = object.context.TouchEnded:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if not player or not playersInZone[player] then
			return
		end

		local humanoid = Util.getHumanoid(player)
		if humanoid then
			humanoid.WalkSpeed = playersInZone[player]
		end

		playersInZone[player] = nil
	end)

	object.Destroyed:Once(function()
		touchConnection:Disconnect()
		touchEndedConnection:Disconnect()

		for player, originalSpeed in playersInZone do
			local humanoid = Util.getHumanoid(player)
			if humanoid then
				humanoid.WalkSpeed = originalSpeed
			end
		end
	end)
end)

return SlowZone


--!strict
--[[
	@class SpeedBoost
	Temporarily increases player walk speed when touched.

	Attributes:
	- attr_multiplier: number (default: 2) - Speed multiplier
	- attr_duration: number (default: 5) - Duration of the boost in seconds
	- attr_cooldown: number (default: 10) - Cooldown before player can get another boost
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "speed_boost"
local IS_SERVER = RunService:IsServer()
local DEFAULT_MULTIPLIER = 2
local DEFAULT_DURATION = 5
local DEFAULT_COOLDOWN = 10

local SpeedBoost: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

SpeedBoost:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local multiplier = object.context:GetAttribute("attr_multiplier") :: number? or DEFAULT_MULTIPLIER
	local duration = object.context:GetAttribute("attr_duration") :: number? or DEFAULT_DURATION
	local cooldown = object.context:GetAttribute("attr_cooldown") :: number? or DEFAULT_COOLDOWN

	local cooldowns: { Player } = {}
	local activeBoosted: { [Player]: number } = {}

	local connection = Util.onPlayerTouch(object.context, function(player: Player)
		if table.find(cooldowns, player) then
			return
		end

		local humanoid = Util.getHumanoid(player)
		if not humanoid then
			return
		end

		table.insert(cooldowns, player)

		local originalSpeed = activeBoosted[player] or humanoid.WalkSpeed
		if not activeBoosted[player] then
			activeBoosted[player] = originalSpeed
		end

		humanoid.WalkSpeed = originalSpeed * multiplier

		task.delay(duration, function()
			if humanoid and humanoid.Parent then
				humanoid.WalkSpeed = originalSpeed
			end
			activeBoosted[player] = nil
		end)

		task.delay(cooldown, function()
			local index = table.find(cooldowns, player)
			if index then
				table.remove(cooldowns, index)
			end
		end)
	end)

	object.Destroyed:Once(function()
		if connection then
			connection:Disconnect()
		end
	end)
end)

return SpeedBoost


--!strict
--[[
	@class SpeedZone
	Increases player movement speed while inside the zone.

	Attributes:
	- attr_multiplier: number (default: 1.5) - Speed multiplier (1.5 = 150% speed)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "speed_zone"
local IS_SERVER = RunService:IsServer()
local DEFAULT_MULTIPLIER = 1.5

local SpeedZone: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

SpeedZone:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local multiplier = object.context:GetAttribute("attr_multiplier") :: number? or DEFAULT_MULTIPLIER

	local playersInZone: { [Player]: number } = {}

	local touchConnection = object.context.Touched:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if not player or playersInZone[player] then
			return
		end

		local humanoid = Util.getHumanoid(player)
		if not humanoid then
			return
		end

		playersInZone[player] = humanoid.WalkSpeed
		humanoid.WalkSpeed = humanoid.WalkSpeed * multiplier
	end)

	local touchEndedConnection = object.context.TouchEnded:Connect(function(hit)
		local player = Util.getPlayerFromPart(hit)
		if not player or not playersInZone[player] then
			return
		end

		local humanoid = Util.getHumanoid(player)
		if humanoid then
			humanoid.WalkSpeed = playersInZone[player]
		end

		playersInZone[player] = nil
	end)

	object.Destroyed:Once(function()
		touchConnection:Disconnect()
		touchEndedConnection:Disconnect()

		for player, originalSpeed in playersInZone do
			local humanoid = Util.getHumanoid(player)
			if humanoid then
				humanoid.WalkSpeed = originalSpeed
			end
		end
	end)
end)

return SpeedZone


--!strict
--[[
	@class Teleporter
	Teleports players between two parts within a Model.

	Structure:
	- Model (tagged with "teleporter")
		- From: BasePart - Entry point
		- To: BasePart - Exit point

	Attributes:
	- attr_cooldown: number (default: 1) - Cooldown between teleports per player
	- attr_one_way: boolean (default: false) - If true, only "From" teleports to "To"
	- attr_teleport_offset: Vector3 (default: 0, 3, 0) - Offset applied when teleporting
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "teleporter"
local IS_SERVER = RunService:IsServer()
local DEFAULT_COOLDOWN = 1
local DEFAULT_ONE_WAY = false
local DEFAULT_TELEPORT_OFFSET = Vector3.new(0, 0, 0)

local Teleporter: CollectionWatcher.CollectionWatcher<Model> = CollectionWatcher.new(COLLECTION_TAG)

Teleporter:RegisterHandler(function(object: CollectionWatcher.CollectionObject<Model>)
	if not IS_SERVER then
		return
	end

	local fromPart = object.context:FindFirstChild("From")
	local toPart = object.context:FindFirstChild("To")

	if not fromPart or not fromPart:IsA("BasePart") then
		warn("Teleporter requires a BasePart named 'From'")
		return
	end

	if not toPart or not toPart:IsA("BasePart") then
		warn("Teleporter requires a BasePart named 'To'")
		return
	end

	local cooldown = object.context:GetAttribute("attr_cooldown") :: number? or DEFAULT_COOLDOWN
	local oneWay = object.context:GetAttribute("attr_one_way") :: boolean? or DEFAULT_ONE_WAY
	local teleportOffset = object.context:GetAttribute("attr_teleport_offset") :: Vector3? or DEFAULT_TELEPORT_OFFSET

	local cooldowns: { [Player]: boolean } = {}

	local function teleportPlayer(player: Player, target: BasePart)
		if cooldowns[player] then
			return
		end

		cooldowns[player] = true

		task.delay(cooldown, function()
			cooldowns[player] = nil
		end)

		local character = player.Character
		if not character then
			return
		end

		local rootPart = character:FindFirstChild("HumanoidRootPart")
		if rootPart and rootPart:IsA("BasePart") then
			rootPart.CFrame = target.CFrame + teleportOffset
		end
	end

	local fromConnection = Util.onPlayerTouch(fromPart, function(player: Player)
		teleportPlayer(player, toPart)
	end)

	local toConnection: RBXScriptConnection?
	if not oneWay then
		toConnection = Util.onPlayerTouch(toPart, function(player: Player)
			teleportPlayer(player, fromPart)
		end)
	end

	object.Destroyed:Once(function()
		fromConnection:Disconnect()
		if toConnection then
			toConnection:Disconnect()
		end
	end)
end)

return Teleporter


--!strict
--[[
	@class Checkpoint
	Checkpoint system for obby games with persistence support.

	Tags:
	- "checkpoint" - Checkpoint marker, sets checkpoint when touched, acts as spawn point
	- "spawn_point" - Optional explicit spawn location (overrides checkpoint position)

	Attributes (checkpoint):
	- attr_checkpoint_number: number (required) - Checkpoint number (1 and up)
	- attr_checkpoint_effect: boolean? - Show visual effect on reach (default: true)

	Attributes (spawn_point):
	- attr_checkpoint_number: number (required) - Associated checkpoint number
	- attr_spawn_offset: Vector3? - Offset from part center (default: 0, 3, 0)

	Signals:
	- PlayerReachedCheckpoint(player: Player, checkpointNumber: number)
	- PlayerSpawnedAtCheckpoint(player: Player, checkpointNumber: number)

	Usage:
	```lua
	local Attrify = require(Packages.Attrify)

	-- When player reaches a new checkpoint, update their profile
	Attrify.Signals.PlayerReachedCheckpoint:Connect(function(player, checkpoint)
		local profile = getProfile(player)
		profile.Data.checkpoint = checkpoint
	end)

	-- When player joins, set their checkpoint from persistence and spawn
	local function onPlayerAdded(player)
		local profile = loadProfile(player)
		local savedCheckpoint = profile.Data.checkpoint or 1

		Attrify.setPlayerCheckpoint(player, savedCheckpoint)

		player.CharacterAdded:Connect(function()
			task.wait(0.1)
			Attrify.spawnPlayerAtCheckpoint(player, savedCheckpoint)
		end)
	end
	```
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Maid = require(Packages.Maid)
local Signal = require(Packages.Signal)
local Util = require(Packages.Util)

-- Constants
local IS_SERVER = RunService:IsServer()
local IS_CLIENT = RunService:IsClient()
local DEFAULT_SPAWN_OFFSET = Vector3.new(0, 3, 0)

-- Signals
local PlayerReachedCheckpoint = Signal.new() :: Signal.Signal<Player, number>
local PlayerSpawnedAtCheckpoint = Signal.new() :: Signal.Signal<Player, number>

-- State
local checkpointParts: { [number]: BasePart } = {}
local spawnPoints: { [number]: { CFrame } } = {}
local playerCheckpoints: { [Player]: number } = {}
local totalCheckpoints: number = 0
local checkpointCount: number = 0

-- Watching state
local isWatching = false
local watchCleanup = Maid.new()

-- Forward declaration
local getSpawnCFrameInternal: (checkpointNumber: number) -> CFrame?

local function spawnPlayerAtCheckpointInternal(player: Player, checkpointNumber: number)
	local character = player.Character
	if not character then
		return
	end

	local spawnCFrame = getSpawnCFrameInternal(checkpointNumber)
	if not spawnCFrame then
		return
	end

	character:PivotTo(spawnCFrame)
	PlayerSpawnedAtCheckpoint:Fire(player, checkpointNumber)
end

local function setupPlayerCheckpoint(player: Player)
	if not playerCheckpoints[player] then
		playerCheckpoints[player] = 1
	end

	-- Spawn at checkpoint when character is added
	local connection = player.CharacterAdded:Connect(function(character: Model)
		-- Wait for character to be ready
		local humanoidRootPart = character:WaitForChild("HumanoidRootPart", 5)
		if not humanoidRootPart then
			return
		end

		local checkpoint = playerCheckpoints[player] or 1
		spawnPlayerAtCheckpointInternal(player, checkpoint)
	end)

	watchCleanup:Add(connection)

	-- Handle existing character
	if player.Character then
		local checkpoint = playerCheckpoints[player] or 1
		spawnPlayerAtCheckpointInternal(player, checkpoint)
	end
end

local function watchForPlayers()
	if isWatching then
		return
	end

	isWatching = true

	-- Set up existing players
	for _, player in Players:GetPlayers() do
		setupPlayerCheckpoint(player)
	end

	-- Listen for new players
	watchCleanup:Add(Players.PlayerAdded:Connect(function(player: Player)
		setupPlayerCheckpoint(player)
	end))

	watchCleanup:Add(function()
		isWatching = false
	end)
end

local function unwatchForPlayers()
	watchCleanup:Clean()
end

--------------------------------------------------------------------------------
-- Visual Effect (Client)
--------------------------------------------------------------------------------

local function playCheckpointEffect(part: BasePart)
	local originalColor = part.Color
	local tweenInfo = TweenInfo.new(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)

	local flashTween = TweenService:Create(part, tweenInfo, {
		Color = Color3.fromRGB(100, 255, 100),
	})

	flashTween:Play()
	flashTween.Completed:Once(function()
		local revertTween = TweenService:Create(part, tweenInfo, {
			Color = originalColor,
		})
		revertTween:Play()
	end)
end

--------------------------------------------------------------------------------
-- Checkpoint Validation
--------------------------------------------------------------------------------

local function canProgressToCheckpoint(player: Player, checkpointNumber: number): boolean
	local currentCheckpoint = playerCheckpoints[player] or 1

	-- Only allow forward progression by exactly one checkpoint
	return checkpointNumber == currentCheckpoint + 1
end

local function trySetCheckpoint(player: Player, checkpointNumber: number): boolean
	if not canProgressToCheckpoint(player, checkpointNumber) then
		return false
	end

	local currentCheckpoint = playerCheckpoints[player] or 1
	if checkpointNumber == currentCheckpoint then
		return false -- Already at this checkpoint
	end

	playerCheckpoints[player] = checkpointNumber
	PlayerReachedCheckpoint:Fire(player, checkpointNumber)
	return true
end

--------------------------------------------------------------------------------
-- Touch Handler Setup
--------------------------------------------------------------------------------

local function setupServerTouchHandler(
	object: CollectionWatcher.CollectionObject<BasePart>,
	checkpointNumber: number
)
	local connection = Util.onPlayerTouch(object.context, function(player: Player)
		trySetCheckpoint(player, checkpointNumber)
	end)

	object.Destroyed:Once(function()
		if connection then
			connection:Disconnect()
		end
	end)

	return connection
end

local function setupClientEffectHandler(
	object: CollectionWatcher.CollectionObject<BasePart>,
	checkpointNumber: number
): RBXScriptConnection?
	local showEffect = object.context:GetAttribute("attr_checkpoint_effect") :: boolean?
	if showEffect == false then
		return nil
	end

	local connection = Util.onPlayerTouch(object.context, function(_player: Player)
		if _player ~= Players.LocalPlayer then
			return
		end

		local currentCheckpoint = playerCheckpoints[_player] or 1
		-- Only show effect if this is the next checkpoint
		if checkpointNumber == currentCheckpoint + 1 then
			playCheckpointEffect(object.context)
		end
	end)

	object.Destroyed:Once(function()
		if connection then
			connection:Disconnect()
		end
	end)

	return connection
end

--------------------------------------------------------------------------------
-- Checkpoint Watcher (tag: "checkpoint")
--------------------------------------------------------------------------------

local CheckpointWatcher: CollectionWatcher.CollectionWatcher<BasePart> =
	CollectionWatcher.new("checkpoint")

CheckpointWatcher:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	local checkpointNumber = object.context:GetAttribute("attr_checkpoint_number") :: number?

	if not checkpointNumber then
		warn("[Checkpoint] Part missing attr_checkpoint_number:", object.context:GetFullName())
		return
	end

	-- Register the checkpoint part (for spawn fallback)
	checkpointParts[checkpointNumber] = object.context
	totalCheckpoints = math.max(totalCheckpoints, checkpointNumber)
	checkpointCount += 1

	if IS_SERVER then
		-- Start watching for players when first checkpoint is detected
		watchForPlayers()

		setupServerTouchHandler(object, checkpointNumber)

		object.Destroyed:Once(function()
			checkpointParts[checkpointNumber] = nil
			checkpointCount -= 1

			-- Recalculate total checkpoints
			totalCheckpoints = 0
			for num, _ in pairs(checkpointParts) do
				totalCheckpoints = math.max(totalCheckpoints, num)
			end

			-- Stop watching if no checkpoints remain
			if checkpointCount == 0 then
				unwatchForPlayers()
			end
		end)
	elseif IS_CLIENT then
		setupClientEffectHandler(object, checkpointNumber)
	end
end)

--------------------------------------------------------------------------------
-- Spawn Point Watcher (tag: "spawn_point")
--------------------------------------------------------------------------------

local SpawnPointWatcher: CollectionWatcher.CollectionWatcher<BasePart> =
	CollectionWatcher.new("spawn_point")

SpawnPointWatcher:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_SERVER then
		return
	end

	local checkpointNumber = object.context:GetAttribute("attr_checkpoint_number") :: number?

	if not checkpointNumber then
		warn("[SpawnPoint] Part missing attr_checkpoint_number:", object.context:GetFullName())
		return
	end

	local offset = object.context:GetAttribute("attr_spawn_offset") :: Vector3? or DEFAULT_SPAWN_OFFSET
	local spawnCFrame = object.context.CFrame + offset

	-- Register spawn point
	if not spawnPoints[checkpointNumber] then
		spawnPoints[checkpointNumber] = {}
	end
	table.insert(spawnPoints[checkpointNumber], spawnCFrame)

	object.Destroyed:Once(function()
		local points = spawnPoints[checkpointNumber]
		if points then
			for i, cf in points do
				if cf == spawnCFrame then
					table.remove(points, i)
					break
				end
			end
		end
	end)
end)

--------------------------------------------------------------------------------
-- Player Cleanup
--------------------------------------------------------------------------------

if IS_SERVER then
	Players.PlayerRemoving:Connect(function(player)
		playerCheckpoints[player] = nil
	end)
end

--------------------------------------------------------------------------------
-- Internal Helpers
--------------------------------------------------------------------------------

getSpawnCFrameInternal = function(checkpointNumber: number): CFrame?
	-- Try spawn_point first
	local points = spawnPoints[checkpointNumber]
	if points and #points > 0 then
		return points[math.random(1, #points)]
	end

	-- Fallback to checkpoint part
	local checkpointPart = checkpointParts[checkpointNumber]
	if checkpointPart then
		return checkpointPart.CFrame + DEFAULT_SPAWN_OFFSET
	end

	return nil
end

--------------------------------------------------------------------------------
-- Module API
--------------------------------------------------------------------------------

local module = {
	-- Signals
	PlayerReachedCheckpoint = PlayerReachedCheckpoint,
	PlayerSpawnedAtCheckpoint = PlayerSpawnedAtCheckpoint,

	--- Get the part for a checkpoint number
	getCheckpointPart = function(checkpointNumber: number): BasePart?
		return checkpointParts[checkpointNumber]
	end,

	--- Get total number of checkpoints
	getTotalCheckpoints = function(): number
		return totalCheckpoints
	end,

	--- Get player's current checkpoint (defaults to 1)
	getPlayerCheckpoint = function(player: Player): number
		return playerCheckpoints[player] or 1
	end,

	--- Set player's checkpoint (for loading from persistence)
	setPlayerCheckpoint = function(player: Player, checkpointNumber: number): boolean
		if checkpointNumber < 1 then
			return false
		end
		playerCheckpoints[player] = checkpointNumber

		-- If player has a character, teleport them to the checkpoint
		if player.Character then
			spawnPlayerAtCheckpointInternal(player, checkpointNumber)
		end

		return true
	end,

	--- Spawn player at checkpoint
	spawnPlayerAtCheckpoint = function(player: Player, checkpointNumber: number): boolean
		if not IS_SERVER then
			warn("[Checkpoint] spawnPlayerAtCheckpoint can only be called on server")
			return false
		end

		local character = player.Character
		if not character then
			return false
		end

		local humanoidRootPart = character:FindFirstChild("HumanoidRootPart") :: BasePart?
		if not humanoidRootPart then
			return false
		end

		local spawnCFrame = getSpawnCFrameInternal(checkpointNumber)
		if not spawnCFrame then
			warn("[Checkpoint] No spawn location for checkpoint", checkpointNumber)
			return false
		end

		humanoidRootPart.CFrame = spawnCFrame
		PlayerSpawnedAtCheckpoint:Fire(player, checkpointNumber)

		return true
	end,

	--- Get spawn CFrame for a checkpoint
	getSpawnCFrame = function(checkpointNumber: number): CFrame?
		return getSpawnCFrameInternal(checkpointNumber)
	end,
}

return module


--!strict
--[[
	@class CyclingKillPart
	Kill part that cycles between active and inactive states.
	Only damages players when visible/active.

	Attributes:
	- attr_active_time: number (default: 2) - Time the part is active (visible and deadly)
	- attr_inactive_time: number (default: 2) - Time the part is inactive (hidden and safe)
	- attr_delay: number (default: 0) - Initial delay before starting cycle
	- attr_damage: number (default: 100) - Amount of damage to deal
	- attr_cooldown: number (default: 1) - Cooldown between damage applications per player
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "cycling_kill_part"
local IS_SERVER = RunService:IsServer()
local IS_CLIENT = RunService:IsClient()
local DEFAULT_ACTIVE_TIME = 2
local DEFAULT_INACTIVE_TIME = 2
local DEFAULT_DELAY = 0
local DEFAULT_DAMAGE = 100
local DEFAULT_COOLDOWN = 1

local CyclingKillPart: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

local function getCycleState(delayTime: number, activeTime: number, totalCycleTime: number): boolean
	local serverTime = workspace:GetServerTimeNow()
	local cyclePosition = (serverTime - delayTime) % totalCycleTime
	return cyclePosition < activeTime
end

CyclingKillPart:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	local activeTime = object.context:GetAttribute("attr_active_time") :: number? or DEFAULT_ACTIVE_TIME
	local inactiveTime = object.context:GetAttribute("attr_inactive_time") :: number? or DEFAULT_INACTIVE_TIME
	local delayTime = object.context:GetAttribute("attr_delay") :: number? or DEFAULT_DELAY

	local totalCycleTime = activeTime + inactiveTime

	if IS_SERVER then
		local damage = object.context:GetAttribute("attr_damage") :: number? or DEFAULT_DAMAGE
		local cooldown = object.context:GetAttribute("attr_cooldown") :: number? or DEFAULT_COOLDOWN

		local cooldowns: { [Player]: boolean } = {}

		local connection = Util.onPlayerTouch(object.context, function(player: Player)
			if not getCycleState(delayTime, activeTime, totalCycleTime) then
				return
			end

			if cooldowns[player] then
				return
			end

			cooldowns[player] = true

			task.delay(cooldown, function()
				cooldowns[player] = nil
			end)

			local humanoid = Util.getHumanoid(player)
			if humanoid then
				humanoid:TakeDamage(damage)
			end
		end)

		object.Destroyed:Once(function()
			connection:Disconnect()
		end)
	elseif IS_CLIENT then
		local originalTransparency = object.context.Transparency

		local connection = RunService.Heartbeat:Connect(function()
			if not object.context or not object.context.Parent then
				return
			end

			local isActive = getCycleState(delayTime, activeTime, totalCycleTime)

			if isActive then
				object.context.Transparency = originalTransparency
				object.context.CanCollide = true
			else
				object.context.Transparency = 1
				object.context.CanCollide = false
			end
		end)

		object.Destroyed:Once(function()
			if connection.Connected then
				connection:Disconnect()
			end
		end)
	end
end)

return CyclingKillPart


--!strict
--[[
	@class CyclingPlatform
	Platform that cycles between visible and hidden states.

	Attributes:
	- attr_visible_time: number (default: 2) - Time the platform is visible
	- attr_hidden_time: number (default: 2) - Time the platform is hidden
	- attr_delay: number (default: 0) - Initial delay before starting cycle
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "cycling_platform"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_VISIBLE_TIME = 2
local DEFAULT_HIDDEN_TIME = 2
local DEFAULT_DELAY = 0

local CyclingPlatform: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

CyclingPlatform:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local visibleTime = object.context:GetAttribute("attr_visible_time") :: number? or DEFAULT_VISIBLE_TIME
	local hiddenTime = object.context:GetAttribute("attr_hidden_time") :: number? or DEFAULT_HIDDEN_TIME
	local delayTime = object.context:GetAttribute("attr_delay") :: number? or DEFAULT_DELAY

	local originalTransparency = object.context.Transparency
	local totalCycleTime = visibleTime + hiddenTime
	local cycleTime = -delayTime

	local connection = RunService.Heartbeat:Connect(function(deltaTime)
		if not object.context or not object.context.Parent then
			return
		end

		cycleTime += deltaTime

		if cycleTime < 0 then
			return
		end

		while cycleTime >= totalCycleTime do
			cycleTime -= totalCycleTime
		end

		local isVisible = cycleTime < visibleTime

		if isVisible then
			object.context.Transparency = originalTransparency
			object.context.CanCollide = true
		else
			object.context.Transparency = 1
			object.context.CanCollide = false
		end
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return CyclingPlatform


--!strict
--[[
	@class AmbientSound
	Plays an ambient looping sound.

	Attributes:
	- attr_sound_id: string (required) - Asset ID of the sound
	- attr_volume: number (default: 0.5) - Sound volume
	- attr_range: number (default: 50) - Maximum audible distance
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "ambient_sound"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_VOLUME = 0.5
local DEFAULT_RANGE = 50

local AmbientSound: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

AmbientSound:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local soundId = object.context:GetAttribute("attr_sound_id") :: string?
	local volume = object.context:GetAttribute("attr_volume") :: number? or DEFAULT_VOLUME
	local range = object.context:GetAttribute("attr_range") :: number? or DEFAULT_RANGE

	if not soundId then
		warn("AmbientSound requires attr_sound_id attribute")
		return
	end

	local sound = Instance.new("Sound")
	sound.SoundId = `rbxassetid://{soundId}`
	sound.Volume = volume
	sound.Looped = true
	sound.RollOffMaxDistance = range
	sound.RollOffMinDistance = range * 0.1
	sound.RollOffMode = Enum.RollOffMode.Linear
	sound.Parent = object.context
	sound:Play()

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_sound_id" then
			local newId = object.context:GetAttribute("attr_sound_id") :: string?
			if newId then
				sound.SoundId = `rbxassetid://{newId}`
				sound:Play()
			end
		elseif attributeName == "attr_volume" then
			sound.Volume = object.context:GetAttribute("attr_volume") :: number? or DEFAULT_VOLUME
		elseif attributeName == "attr_range" then
			local newRange = object.context:GetAttribute("attr_range") :: number? or DEFAULT_RANGE
			sound.RollOffMaxDistance = newRange
			sound.RollOffMinDistance = newRange * 0.1
		end
	end)

	object.Destroyed:Once(function()
		attributeConnection:Disconnect()
		sound:Destroy()
	end)
end)

return AmbientSound


--!strict
--[[
	@class ProximitySound
	Plays a sound when a player comes within range.

	Attributes:
	- attr_sound_id: string (required) - Asset ID of the sound
	- attr_volume: number (default: 0.5) - Sound volume
	- attr_range: number (default: 20) - Trigger distance
	- attr_cooldown: number (default: 5) - Minimum time between plays
]]

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

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "proximity_sound"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_VOLUME = 0.5
local DEFAULT_RANGE = 20
local DEFAULT_COOLDOWN = 5

local ProximitySound: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

ProximitySound:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local soundId = object.context:GetAttribute("attr_sound_id") :: string?
	local volume = object.context:GetAttribute("attr_volume") :: number? or DEFAULT_VOLUME
	local range = object.context:GetAttribute("attr_range") :: number? or DEFAULT_RANGE
	local cooldown = object.context:GetAttribute("attr_cooldown") :: number? or DEFAULT_COOLDOWN

	if not soundId then
		warn("ProximitySound requires attr_sound_id attribute")
		return
	end

	local sound = Instance.new("Sound")
	sound.SoundId = `rbxassetid://{soundId}`
	sound.Volume = volume
	sound.Parent = object.context

	local isOnCooldown = false
	local wasInRange = false

	local connection = RunService.Heartbeat:Connect(function()
		if not object.context or not object.context.Parent then
			return
		end

		local partPosition = object.context.Position
		local isInRange = false

		for _, player in Players:GetPlayers() do
			local character = player.Character
			if character then
				local rootPart = character:FindFirstChild("HumanoidRootPart")
				if rootPart and rootPart:IsA("BasePart") then
					local distance = (rootPart.Position - partPosition).Magnitude
					if distance <= range then
						isInRange = true
						break
					end
				end
			end
		end

		if isInRange and not wasInRange and not isOnCooldown then
			isOnCooldown = true
			sound:Play()

			task.delay(cooldown, function()
				isOnCooldown = false
			end)
		end

		wasInRange = isInRange
	end)

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_sound_id" then
			local newId = object.context:GetAttribute("attr_sound_id") :: string?
			if newId then
				sound.SoundId = `rbxassetid://{newId}`
			end
		elseif attributeName == "attr_volume" then
			sound.Volume = object.context:GetAttribute("attr_volume") :: number? or DEFAULT_VOLUME
		end
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
		attributeConnection:Disconnect()
		sound:Destroy()
	end)
end)

return ProximitySound


--!strict
--[[
	@class TouchSound
	Plays a sound when a player touches the part.

	Attributes:
	- attr_sound_id: string (required) - Asset ID of the sound
	- attr_volume: number (default: 0.5) - Sound volume
	- attr_cooldown: number (default: 0.5) - Minimum time between plays
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent
local Packages = Attrify

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)
local Util = require(Packages.Util)

local COLLECTION_TAG = "touch_sound"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_VOLUME = 0.5
local DEFAULT_COOLDOWN = 0.5

local TouchSound: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

TouchSound:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local soundId = object.context:GetAttribute("attr_sound_id") :: string?
	local volume = object.context:GetAttribute("attr_volume") :: number? or DEFAULT_VOLUME
	local cooldown = object.context:GetAttribute("attr_cooldown") :: number? or DEFAULT_COOLDOWN

	if not soundId then
		warn("TouchSound requires attr_sound_id attribute")
		return
	end

	local sound = Instance.new("Sound")
	sound.SoundId = `rbxassetid://{soundId}`
	sound.Volume = volume
	sound.Parent = object.context

	local isOnCooldown = false

	local connection = Util.onPlayerTouch(object.context, function(_player: Player)
		if isOnCooldown then
			return
		end

		isOnCooldown = true
		sound:Play()

		task.delay(cooldown, function()
			isOnCooldown = false
		end)
	end)

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_sound_id" then
			local newId = object.context:GetAttribute("attr_sound_id") :: string?
			if newId then
				sound.SoundId = `rbxassetid://{newId}`
			end
		elseif attributeName == "attr_volume" then
			sound.Volume = object.context:GetAttribute("attr_volume") :: number? or DEFAULT_VOLUME
		end
	end)

	object.Destroyed:Once(function()
		if connection then
			connection:Disconnect()
		end
		attributeConnection:Disconnect()
		sound:Destroy()
	end)
end)

return TouchSound


--!strict
--[[
	@class Beamer
	Creates a beam between this part and a target part.

	Attributes:
	- attr_target_name: string (required) - Name of the target part
	- attr_beam_color: Color3 (default: white) - Color of the beam
	- attr_width: number (default: 1) - Width of the beam
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "beamer"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_BEAM_COLOR = Color3.new(1, 1, 1)
local DEFAULT_WIDTH = 1

local Beamer: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Beamer:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local targetName = object.context:GetAttribute("attr_target_name") :: string?
	local beamColor = object.context:GetAttribute("attr_beam_color") :: Color3? or DEFAULT_BEAM_COLOR
	local width = object.context:GetAttribute("attr_width") :: number? or DEFAULT_WIDTH

	if not targetName then
		warn("Beamer requires attr_target_name attribute")
		return
	end

	local target = workspace:FindFirstChild(targetName, true)
	if not target or not target:IsA("BasePart") then
		warn(`Beamer target "{targetName}" not found`)
		return
	end

	local attachment0 = Instance.new("Attachment")
	attachment0.Parent = object.context

	local attachment1 = Instance.new("Attachment")
	attachment1.Parent = target

	local beam = Instance.new("Beam")
	beam.Attachment0 = attachment0
	beam.Attachment1 = attachment1
	beam.Color = ColorSequence.new(beamColor)
	beam.Width0 = width
	beam.Width1 = width
	beam.FaceCamera = true
	beam.Parent = object.context

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_beam_color" then
			local newColor = object.context:GetAttribute("attr_beam_color") :: Color3? or DEFAULT_BEAM_COLOR
			beam.Color = ColorSequence.new(newColor)
		elseif attributeName == "attr_width" then
			local newWidth = object.context:GetAttribute("attr_width") :: number? or DEFAULT_WIDTH
			beam.Width0 = newWidth
			beam.Width1 = newWidth
		end
	end)

	object.Destroyed:Once(function()
		attributeConnection:Disconnect()
		beam:Destroy()
		attachment0:Destroy()
		attachment1:Destroy()
	end)
end)

return Beamer


--!strict
--[[
	@class Billboard
	Adds a BillboardGui with text above a part.

	Attributes:
	- attr_text: string (default: "") - Text to display
	- attr_text_color: Color3 (default: white) - Text color
	- attr_text_size: number (default: 24) - Text size
	- attr_offset: number (default: 3) - Offset above the part
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "billboard"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_TEXT = ""
local DEFAULT_TEXT_COLOR = Color3.new(1, 1, 1)
local DEFAULT_TEXT_SIZE = 24
local DEFAULT_OFFSET = 3

local BillboardWatcher: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

BillboardWatcher:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local text = object.context:GetAttribute("attr_text") :: string? or DEFAULT_TEXT
	local textColor = object.context:GetAttribute("attr_text_color") :: Color3? or DEFAULT_TEXT_COLOR
	local textSize = object.context:GetAttribute("attr_text_size") :: number? or DEFAULT_TEXT_SIZE
	local offset = object.context:GetAttribute("attr_offset") :: number? or DEFAULT_OFFSET

	local billboard = Instance.new("BillboardGui")
	billboard.Size = UDim2.new(0, 200, 0, 50)
	billboard.StudsOffset = Vector3.new(0, offset, 0)
	billboard.AlwaysOnTop = true
	billboard.Parent = object.context

	local textLabel = Instance.new("TextLabel")
	textLabel.Size = UDim2.new(1, 0, 1, 0)
	textLabel.BackgroundTransparency = 1
	textLabel.Text = text
	textLabel.TextColor3 = textColor
	textLabel.TextSize = textSize
	textLabel.Font = Enum.Font.GothamBold
	textLabel.TextScaled = false
	textLabel.Parent = billboard

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_text" then
			textLabel.Text = object.context:GetAttribute("attr_text") :: string? or DEFAULT_TEXT
		elseif attributeName == "attr_text_color" then
			textLabel.TextColor3 = object.context:GetAttribute("attr_text_color") :: Color3? or DEFAULT_TEXT_COLOR
		elseif attributeName == "attr_text_size" then
			textLabel.TextSize = object.context:GetAttribute("attr_text_size") :: number? or DEFAULT_TEXT_SIZE
		elseif attributeName == "attr_offset" then
			billboard.StudsOffset = Vector3.new(
				0,
				object.context:GetAttribute("attr_offset") :: number? or DEFAULT_OFFSET,
				0
			)
		end
	end)

	object.Destroyed:Once(function()
		attributeConnection:Disconnect()
		billboard:Destroy()
	end)
end)

return BillboardWatcher


--!strict
--[[
	@class Flickerer
	Makes a part flicker on and off randomly.

	Attributes:
	- attr_flicker_speed: number (default: 10) - Average flickers per second
	- attr_flicker_chance: number (default: 0.3) - Chance to be "off" (0-1)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "flickerer"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_FLICKER_SPEED = 10
local DEFAULT_FLICKER_CHANCE = 0.3

local Flickerer: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Flickerer:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local flickerSpeed = object.context:GetAttribute("attr_flicker_speed") :: number? or DEFAULT_FLICKER_SPEED
	local flickerChance = object.context:GetAttribute("attr_flicker_chance") :: number? or DEFAULT_FLICKER_CHANCE

	local originalTransparency = object.context.Transparency
	local elapsedTime = 0
	local nextFlickerTime = 0
	local _isFlickeredOff = false

	local connection = RunService.Heartbeat:Connect(function(deltaTime)
		if not object.context or not object.context.Parent then
			return
		end

		elapsedTime += deltaTime

		if elapsedTime >= nextFlickerTime then
			local interval = 1 / flickerSpeed
			nextFlickerTime = elapsedTime + interval * (0.5 + math.random())

			if math.random() < flickerChance then
				_isFlickeredOff = true
				object.context.Transparency = 1
			else
				_isFlickeredOff = false
				object.context.Transparency = originalTransparency
			end
		end
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return Flickerer


--!strict
--[[
	@class Glowing
	Adds a neon glow effect to a part.

	Attributes:
	- attr_glow_color: Color3 (optional) - Color of the glow (defaults to part color)
	- attr_brightness: number (default: 1) - PointLight brightness
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "glowing"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_BRIGHTNESS = 1

local Glowing: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Glowing:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local glowColor = object.context:GetAttribute("attr_glow_color") :: Color3? or object.context.Color
	local brightness = object.context:GetAttribute("attr_brightness") :: number? or DEFAULT_BRIGHTNESS

	object.context.Material = Enum.Material.Neon

	local pointLight = Instance.new("PointLight")
	pointLight.Color = glowColor
	pointLight.Brightness = brightness
	pointLight.Range = math.max(object.context.Size.X, object.context.Size.Y, object.context.Size.Z) * 2
	pointLight.Parent = object.context

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_glow_color" then
			local newColor = object.context:GetAttribute("attr_glow_color") :: Color3?
			if newColor then
				pointLight.Color = newColor
			end
		elseif attributeName == "attr_brightness" then
			pointLight.Brightness = object.context:GetAttribute("attr_brightness") :: number? or DEFAULT_BRIGHTNESS
		end
	end)

	object.Destroyed:Once(function()
		attributeConnection:Disconnect()
		pointLight:Destroy()
	end)
end)

return Glowing


--!strict
--[[
	@class Spotlighter
	Adds a spotlight to a part. The spotlight uses the bottom face.

	Attributes:
	- attr_range: number (default: 16) - Light range
	- attr_angle: number (default: 90) - Spotlight angle in degrees
	- attr_light_color: Color3 (default: white) - Color of the light
	- attr_brightness: number (default: 1) - Light brightness
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "spotlighter"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_RANGE = 16
local DEFAULT_ANGLE = 180
local DEFAULT_LIGHT_COLOR = Color3.new(1, 1, 1)
local DEFAULT_BRIGHTNESS = 1

local Spotlighter: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Spotlighter:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local range = object.context:GetAttribute("attr_range") :: number? or DEFAULT_RANGE
	local angle = object.context:GetAttribute("attr_angle") :: number? or DEFAULT_ANGLE
	local lightColor = object.context:GetAttribute("attr_light_color") :: Color3? or DEFAULT_LIGHT_COLOR
	local brightness = object.context:GetAttribute("attr_brightness") :: number? or DEFAULT_BRIGHTNESS

	local spotlight = Instance.new("SpotLight")
	spotlight.Range = range
	spotlight.Angle = angle
	spotlight.Color = lightColor
	spotlight.Brightness = brightness
	spotlight.Face = Enum.NormalId.Bottom
	spotlight.Parent = object.context

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_range" then
			spotlight.Range = object.context:GetAttribute("attr_range") :: number? or DEFAULT_RANGE
		elseif attributeName == "attr_angle" then
			spotlight.Angle = object.context:GetAttribute("attr_angle") :: number? or DEFAULT_ANGLE
		elseif attributeName == "attr_light_color" then
			spotlight.Color = object.context:GetAttribute("attr_light_color") :: Color3? or DEFAULT_LIGHT_COLOR
		elseif attributeName == "attr_brightness" then
			spotlight.Brightness = object.context:GetAttribute("attr_brightness") :: number? or DEFAULT_BRIGHTNESS
		end
	end)

	object.Destroyed:Once(function()
		attributeConnection:Disconnect()
		spotlight:Destroy()
	end)
end)

return Spotlighter


--!strict
--[[
	@class Trail
	Adds a trail attachment to a part.

	Attributes:
	- attr_trail_color: Color3 (default: white) - Color of the trail
	- attr_lifetime: number (default: 1) - Trail lifetime in seconds
	- attr_width: number (default: 1) - Trail width
	- attr_axis: Vector3 (default: 0, 1, 0) - Axis for trail attachments (X, Y, or Z)
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "trail"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_TRAIL_COLOR = Color3.new(1, 1, 1)
local DEFAULT_LIFETIME = 1
local DEFAULT_WIDTH = 1
local DEFAULT_AXIS = Vector3.yAxis

local Trail: CollectionWatcher.CollectionWatcher<BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Trail:RegisterHandler(function(object: CollectionWatcher.CollectionObject<BasePart>)
	if not IS_CLIENT then
		return
	end

	local trailColor = object.context:GetAttribute("attr_trail_color") :: Color3? or DEFAULT_TRAIL_COLOR
	local lifetime = object.context:GetAttribute("attr_lifetime") :: number? or DEFAULT_LIFETIME
	local width = object.context:GetAttribute("attr_width") :: number? or DEFAULT_WIDTH
	local axis = object.context:GetAttribute("attr_axis") :: Vector3? or DEFAULT_AXIS

	local normalizedAxis = axis.Unit
	local halfSize = (object.context.Size * normalizedAxis).Magnitude / 2

	local attachment0 = Instance.new("Attachment")
	attachment0.Position = normalizedAxis * halfSize
	attachment0.Parent = object.context

	local attachment1 = Instance.new("Attachment")
	attachment1.Position = normalizedAxis * -halfSize
	attachment1.Parent = object.context

	local trail = Instance.new("Trail")
	trail.Attachment0 = attachment0
	trail.Attachment1 = attachment1
	trail.Color = ColorSequence.new(trailColor)
	trail.Lifetime = lifetime
	trail.MinLength = 0.1
	trail.WidthScale = NumberSequence.new(width)
	trail.Parent = object.context

	local attributeConnection = object.context.AttributeChanged:Connect(function(attributeName)
		if attributeName == "attr_trail_color" then
			local newColor = object.context:GetAttribute("attr_trail_color") :: Color3? or DEFAULT_TRAIL_COLOR
			trail.Color = ColorSequence.new(newColor)
		elseif attributeName == "attr_lifetime" then
			trail.Lifetime = object.context:GetAttribute("attr_lifetime") :: number? or DEFAULT_LIFETIME
		elseif attributeName == "attr_width" then
			local newWidth = object.context:GetAttribute("attr_width") :: number? or DEFAULT_WIDTH
			trail.WidthScale = NumberSequence.new(newWidth)
		end
	end)

	object.Destroyed:Once(function()
		attributeConnection:Disconnect()
		trail:Destroy()
		attachment0:Destroy()
		attachment1:Destroy()
	end)
end)

return Trail