Tweening Number & Color Sequences [v3.0]

Summary

I was trying to tween number sequences and I found out that you cannot, not even the keypoints, so I just made one because nobody else did!

Features:
All tween styles and directions,
Tweens to the nearest timestamp value but doesn’t change the original timestamp
Tweens color, size, and transparency

Cons:
Script yields to function to cancel this you can just use: task.spawn()
task.spawn documentation

Instead of just calling the function

Main Script Here v

Script
local function lerp(a, b, t)
	return a + (b - a) * t
end
function lerpColor3(a, b, t)
	local lerpedColor = Color3.new(
		a.r + (b.r - a.r) * t,
		a.g + (b.g - a.g) * t,
		a.b + (b.b - a.b) * t
	)
	return lerpedColor
end

local function tweenSequence(sequence, targetSequence, smoothness, timeTaken, objectToUpdate, propertyName, easeStyle, easeDirection)
	local sequenceType = false
	if typeof(sequence) == "NumberSequence" then
		sequenceType = "NumberSequence"
	elseif typeof(sequence) == "ColorSequence" then
		sequenceType = "ColorSequence"
	end
	assert(smoothness and type(smoothness) == "number" and smoothness > 0, "Invalid smoothness")
	assert(timeTaken and type(timeTaken) == "number" and timeTaken > 0, "Invalid timeTaken")
	assert(type(objectToUpdate[propertyName]) == "userdata" , "Invalid objectToUpdate")
	assert(propertyName and type(propertyName) == "string", "Invalid propertyName")
	if sequenceType == "NumberSequence" then
		local keypoints = sequence.Keypoints
		local targetKeypoints = targetSequence.Keypoints

		local originalTimes = {}
		local originalValues = {}
		local originalEnvelopes = {}
		for _, keypoint in ipairs(keypoints) do
			table.insert(originalTimes, keypoint.Time)
			table.insert(originalValues, keypoint.Value)
			table.insert(originalEnvelopes, keypoint.Envelope)
		end

		local function updateNumberSequence(progress)
			local newKeypoints = {}
			for i, originalTime in ipairs(originalTimes) do
				local closestTargetKeypointIndex = 1
				local closestTimeDifference = math.abs(originalTime - targetKeypoints[1].Time)
				for j, targetKeypoint in ipairs(targetKeypoints) do
					local timeDifference = math.abs(originalTime - targetKeypoint.Time)
					if timeDifference < closestTimeDifference then
						closestTimeDifference = timeDifference
						closestTargetKeypointIndex = j
					end
				end

				local targetValue = targetKeypoints[closestTargetKeypointIndex].Value
				local targetEnvelope = targetKeypoints[closestTargetKeypointIndex].Envelope

				local newValue = lerp(originalValues[i], targetValue, progress)
				local newEnvelope = lerp(originalEnvelopes[i], targetEnvelope, progress)
				table.insert(newKeypoints, NumberSequenceKeypoint.new(originalTime, newValue, newEnvelope))
			end
			return NumberSequence.new(newKeypoints)
		end

		for t = 0, 1, 1 / (smoothness * timeTaken) do
			local ta = game.TweenService:GetValue(t, easeStyle, easeDirection)
			local newNumberSequence = updateNumberSequence(ta)
			objectToUpdate[propertyName] = newNumberSequence
			task.wait(1 / smoothness)
		end

		local newKeypoints = {}
		for i, originalTime in ipairs(originalTimes) do
			local closestTargetKeypointIndex = 1
			local closestTimeDifference = math.abs(originalTime - targetKeypoints[1].Time)
			for j, targetKeypoint in ipairs(targetKeypoints) do
				local timeDifference = math.abs(originalTime - targetKeypoint.Time)
				if timeDifference < closestTimeDifference then
					closestTimeDifference = timeDifference
					closestTargetKeypointIndex = j
				end
			end

			local targetValue = targetKeypoints[closestTargetKeypointIndex].Value
			local targetEnvelope = targetKeypoints[closestTargetKeypointIndex].Envelope

			table.insert(newKeypoints, NumberSequenceKeypoint.new(originalTime, targetValue, targetValue))
		end
		objectToUpdate[propertyName] = NumberSequence.new(newKeypoints)
	elseif sequenceType == "ColorSequence" then
		local keypoints = sequence.Keypoints
		local targetKeypoints = targetSequence.Keypoints

		local originalTimes = {}
		local originalValues = {}
		for _, keypoint in ipairs(keypoints) do
			table.insert(originalTimes, keypoint.Time)
			table.insert(originalValues, keypoint.Value)
		end

		local function updateColorSequence(progress)
			local newKeypoints = {}
			for i, originalTime in ipairs(originalTimes) do
				local closestTargetKeypointIndex = 1
				local closestTimeDifference = math.abs(originalTime - targetKeypoints[1].Time)
				for j, targetKeypoint in ipairs(targetKeypoints) do
					local timeDifference = math.abs(originalTime - targetKeypoint.Time)
					if timeDifference < closestTimeDifference then
						closestTimeDifference = timeDifference
						closestTargetKeypointIndex = j
					end
				end

				local targetValue = targetKeypoints[closestTargetKeypointIndex].Value

				local newValue = lerpColor3(originalValues[i], targetValue, progress)
				table.insert(newKeypoints, ColorSequenceKeypoint.new(originalTime, newValue))
			end
			return ColorSequence.new(newKeypoints)
		end

		for t = 0, 1, 1 / (smoothness * timeTaken) do
			local ta = game.TweenService:GetValue(t, easeStyle, easeDirection)
			local newColorSequence = updateColorSequence(ta)
			objectToUpdate[propertyName] = newColorSequence
			task.wait(1 / smoothness)
		end

		local newKeypoints = {}
		for i, originalTime in ipairs(originalTimes) do
			local closestTargetKeypointIndex = 1
			local closestTimeDifference = math.abs(originalTime - targetKeypoints[1].Time)
			for j, targetKeypoint in ipairs(targetKeypoints) do
				local timeDifference = math.abs(originalTime - targetKeypoint.Time)
				if timeDifference < closestTimeDifference then
					closestTimeDifference = timeDifference
					closestTargetKeypointIndex = j
				end
			end

			local targetValue = targetKeypoints[closestTargetKeypointIndex].Value

			table.insert(newKeypoints, ColorSequenceKeypoint.new(originalTime, targetValue, targetValue))
		end
		objectToUpdate[propertyName] = ColorSequence.new(newKeypoints)
	end
end

-- fancy changeable values down here
local objectToUpdate = game.Workspace.Partt.ParticleEmitter
local attributeTweened = "Transparency"
local sequence = objectToUpdate[attributeTweened] -- don't change
local targetSequence = NumberSequence.new({
	NumberSequenceKeypoint.new(0,0,0),
	NumberSequenceKeypoint.new(1,0,0),
})
--[[ for color

local targetSequence = ColorSequence.new({
	ColorSequenceKeypoint.new(0, Color3.new(1,0,0)),
	ColorSequenceKeypoint.new(1, Color3.new(0,0,1))
})

]]
local smoothness = 50 -- How smooth the tweening less smoothing = more choppy
local timeTaken = 5 -- In seconds
local easeStyle = Enum.EasingStyle.Exponential
local easeDirection = Enum.EasingDirection.Out
wait(3)
print('started')
task.spawn(tweenSequence, sequence, targetSequence, smoothness, timeTaken, objectToUpdate, attributeTweened, easeStyle, easeDirection)

Video Demonstration

ExponentialOut 5-second tween
Looks lame but it’s never been done before so it’s cool I guess…


code used for video

local objectToUpdate = game.Workspace.Part.ParticleEmitter
local attributeTweened = "Transparency"
local sequence = objectToUpdate[attributeTweened]
local targetSequence = NumberSequence.new({
	NumberSequenceKeypoint.new(0,0,0),
	NumberSequenceKeypoint.new(1,0,0),
})
local smoothness = 50 -- How smooth the tweening less smoothing = more choppy
local timeTaken = 5 -- In seconds
local easeStyle = Enum.EasingStyle.Exponential
local easeDirection = Enum.EasingDirection.Out
wait(3)
print('started')
task.spawn(tweenSequence, sequence, targetSequence, smoothness, timeTaken, objectToUpdate, attributeTweened, easeStyle, easeDirection)
wait(1)
targetSequence = ColorSequence.new({
	ColorSequenceKeypoint.new(0, Color3.new(1,0,0)),
	ColorSequenceKeypoint.new(1, Color3.new(0,0,1))
})
attributeTweened = "Color"
sequence = objectToUpdate[attributeTweened] -- always make sure to re-evaluate this after changing attributeTweened
task.spawn(tweenSequence, sequence, targetSequence, smoothness, timeTaken, objectToUpdate, attributeTweened, easeStyle, easeDirection)

Credits:
@Katrist: Small script timing optimization

@InKrnl: Module script version (I don’t know if it works because I forgot how to module)
Features: Info

Module Script
-- SERVICES --
local TS = game:GetService("TweenService")

-- AUXILIARY FUNCTIONS --
local function lerp(a, b, t)
	return a + (b - a) * t
end

local function interpolateKeypoints(kp1, kp2, t)
	return NumberSequenceKeypoint.new(
		lerp(kp1.Time, kp2.Time, t),
		lerp(kp1.Value, kp2.Value, t),
		lerp(kp1.Envelope, kp2.Envelope, t)
	)
end

local function getNewValues(progress: number, originalSequence : NumberSequence, targetSequence: NumberSequence)
	local newKeypoints = {}

	local originalKeypoints = originalSequence.Keypoints
	local targetKeypoints = targetSequence.Keypoints

	local originalNumKeypoints = #originalKeypoints
	local targetNumKeypoints = #targetKeypoints

	for i = 1, math.max(originalNumKeypoints, targetNumKeypoints) do
		local originalIndex = math.floor((i - 1) * (originalNumKeypoints - 1) / (targetNumKeypoints - 1)) + 1
		local targetIndex = i

		if originalIndex < 1 then
			originalIndex = 1
		elseif originalIndex > originalNumKeypoints then
			originalIndex = originalNumKeypoints
		end

		local originalKeypoint = originalKeypoints[originalIndex]
		local targetKeypoint = targetKeypoints[targetIndex]

		local t = progress

		local newTime = lerp(originalKeypoint.Time, targetKeypoint.Time, t)
		local newValue = lerp(originalKeypoint.Value, targetKeypoint.Value, t)
		local newEnvelope = lerp(originalKeypoint.Envelope, targetKeypoint.Envelope, t)

		table.insert(newKeypoints, NumberSequenceKeypoint.new(newTime, newValue, newEnvelope))
	end

	return NumberSequence.new(newKeypoints)
end

-- TYPES --
type TweenObject = {
	PlaybackState : Enum.PlaybackState,
	Cancel : (TweenObject) -> TweenObject,
	Play : (TweenObject) -> TweenObject,
	Pause : (TweenObject) -> TweenObject,
	Destroy : (TweenObject) -> nil,
	
	Completed : RBXScriptSignal,
	Paused : RBXScriptSignal,
}

-- MAIN --
local Tween = {}
Tween.__index = Tween

function Tween.new(Object : NumberSequence | ParticleEmitter | Beam, TargetSequence : NumberSequence, Info : TweenInfo, PropertyName : string?) : TweenObject
	local ObjectType : 'NumberSequence' | 'Instance' = 'NumberSequence'
	
	if not Info or typeof(Info) ~= 'TweenInfo' then warn("TweenInfo can't be NIL!") return end
	if typeof(Object) ~= 'NumberSequence' then
		if typeof(Object) ~= 'Instance' or not Object:IsA("ParticleEmitter") and not Object:IsA("Beam") then warn("Invalid object") return end
		if not PropertyName or typeof(PropertyName) ~= 'string' then warn("Invalid property name: ", PropertyName) return end

		ObjectType = "Instance" -- If the property does not exist in the instance, this will likely error
		if typeof(Object[PropertyName]) ~= 'NumberSequence' then warn("Invalid property type: ", typeof(Object)) return end
	end

	local self = setmetatable({}, Tween)
	self.PlaybackState = Enum.PlaybackState.Begin
	
	local completedEvent = Instance.new("BindableEvent")
	local pausedEvent = Instance.new("BindableEvent")
	
	self.Completed = completedEvent.Event
	self.Paused = pausedEvent.Event
	
	local startTick = tick()
	local endTime = startTick + Info.Time
	local pauseTime = tick()
	local activeTask : thread? = nil

	local originalValues = {}

	for i, keypoint : NumberSequenceKeypoint in ipairs(ObjectType == 'NumberSequence' and Object.Keypoints or Object[PropertyName].Keypoints) do
		table.insert(originalValues, keypoint)
	end
	
	local originalSequenceCopy = NumberSequence.new(originalValues)

	function self:Cancel()
		if not self.PlaybackState or self.PlaybackState == Enum.PlaybackState.Completed or self.PlaybackState == Enum.PlaybackState.Cancelled then return self end
		self.PlaybackState = Enum.PlaybackState.Cancelled
		
		return self
	end

	function self:Destroy()
		self:Cancel()
		
		if activeTask then
			task.cancel(activeTask)
			activeTask = nil
		end
		
		pausedEvent:Destroy()
		completedEvent:Destroy()
		self.Completed = nil
		self.Paused = nil
		
		return self
	end

	function self:Play()
		if not self.PlaybackState or self.PlaybackState == Enum.PlaybackState.Completed or self.PlaybackState == Enum.PlaybackState.Cancelled then warn(1) return self end
		
		if self.PlaybackState == Enum.PlaybackState.Paused then
			local currentTime = tick()
			local timePaused = currentTime - pauseTime
			startTick = startTick + timePaused
			endTime = endTime + timePaused
		elseif self.PlaybackState == Enum.PlaybackState.Begin then
			startTick = tick()
			endTime = startTick + Info.Time
		end
		
		self.PlaybackState = Enum.PlaybackState.Playing
		
		if not activeTask then
			activeTask = task.spawn(function()
				while tick() < endTime do
					if tick() >= endTime then break end
					if self.PlaybackState == Enum.PlaybackState.Cancelled then break end

					if self.PlaybackState == Enum.PlaybackState.Paused then
						task.wait()
						continue
					end

					local timePassed = tick() - startTick
					local currentProgress = math.clamp(timePassed / Info.Time, 0, 1)
					currentProgress = :GetValue(currentProgress, Info.EasingStyle, Info.EasingDirection)

					local newSequence = getNewValues(currentProgress, originalSequenceCopy, TargetSequence)

					if ObjectType == 'NumberSequence' then
						Object = newSequence
					else
						Object[PropertyName] = newSequence
					end

					task.wait()
				end

				if self.PlaybackState == Enum.PlaybackState.Playing or self.PlaybackState == Enum.PlaybackState.Cancelled then
					self.PlaybackState = Enum.PlaybackState.Completed
					completedEvent:Fire()
				end
			end)
		end

		return self
	end
	
	function self:Pause()
		if self.PlaybackState == Enum.PlaybackState.Playing then
			self.PlaybackState = Enum.PlaybackState.Paused
			pauseTime = tick()
			pausedEvent:Fire()
		end
		
		return self
	end
	
	return self
end

return Tween

Dear ROBLOX,

Please add this as a normal feature instead of these ~150 lines of code.

13 Likes

You should be using task.wait(n) instead of wait(n), otherwise this looks good.

Code:

for t = 0, 1, 1 / (smoothness * timeTaken) do
	local newNumberSequence = updateNumberSequence(t)
	objectToUpdate[propertyName] = newNumberSequence
	task.wait(1 / smoothness)
end
2 Likes

Could this be made to actually use a TweenInfo datatype?

Hi, I did some modifications to the code, but I didn’t get to do a full testing on it yet and I can’t record it right now.

What I added:

  • Support to EasingStyles and EasingDirections,
  • Support to Envelope,
  • Tweens the time and adds extra keypoints if the TargetSequence has more keypoints than the original Sequence.
  • Changed the structure to be into a module.

Example:

Example Script
-- SERVICES --
local RS = game:GetService('ReplicatedStorage')

-- UTILS --
local Module = require(RS.Utils.NumberSequenceTween) -- Require the module, change the path based on your game

-- MAIN --
local ParticleEmitter = Instance.new("ParticleEmitter")
ParticleEmitter.Parent = workspace.Baseplate -- Assuming you have one

local TargetSequence = NumberSequence.new({ -- The goal sequence
	NumberSequenceKeypoint.new(0, 0), 
	NumberSequenceKeypoint.new(0.5, 2, 5), 
	NumberSequenceKeypoint.new(1, 0)
})

local Info = TweenInfo.new(
	10,
	Enum.EasingStyle.Bounce,
	Enum.EasingDirection.InOut
)

local PropertyName = "Size" -- Property that will get tweened, only needed if the target is an instance

local Tween = Module.new(ParticleEmitter, TargetSequence, Info, PropertyName)

Tween.Paused:Connect(function()
	warn("Tween has been paused!")
end)

Tween.Completed:Connect(function()
	warn("Tween has been completed!")
end)

Tween:Play()

task.wait(4)

Tween:Pause()

task.wait(3)

Tween:Play()

task.wait(3)

Tween:Pause()

task.wait(2)

Tween:Play()

task.wait(1)

Tween:Destroy()

warn("Tween destroyed!")

Methods

  • Tween:Play(),
  • Tween:Cancel(),
  • Tween:Pause(),
  • Tween:Destroy()

Constructor:

Used to create a NumberSequenceTween object.

Module.new()

  • Parameters:
    • [1] Object : NumberSequence | Beam | ParticleEmitter
    • [2] TargetSequence : NumberSequence
    • [3] Info : TweenInfo
    • [4] PropertyName : string? — This is only required if the Object is an Instance.

Events:

  • Completed – Fires when the Tween is Completed or Cancelled.
  • Paused – Fires when the Tween is paused.

Source:

Script Source
-- SERVICES --
local TS = game:GetService("TweenService")

-- AUXILIARY FUNCTIONS --
local function lerp(a, b, t)
	return a + (b - a) * t
end

local function interpolateKeypoints(kp1, kp2, t)
	return NumberSequenceKeypoint.new(
		lerp(kp1.Time, kp2.Time, t),
		lerp(kp1.Value, kp2.Value, t),
		lerp(kp1.Envelope, kp2.Envelope, t)
	)
end

local function getNewValues(progress: number, originalSequence : NumberSequence, targetSequence: NumberSequence)
	local newKeypoints = {}

	local originalKeypoints = originalSequence.Keypoints
	local targetKeypoints = targetSequence.Keypoints

	local originalNumKeypoints = #originalKeypoints
	local targetNumKeypoints = #targetKeypoints

	for i = 1, math.max(originalNumKeypoints, targetNumKeypoints) do
		local originalIndex = math.floor((i - 1) * (originalNumKeypoints - 1) / (targetNumKeypoints - 1)) + 1
		local targetIndex = i

		if originalIndex < 1 then
			originalIndex = 1
		elseif originalIndex > originalNumKeypoints then
			originalIndex = originalNumKeypoints
		end

		local originalKeypoint = originalKeypoints[originalIndex]
		local targetKeypoint = targetKeypoints[targetIndex]

		local t = progress

		local newTime = lerp(originalKeypoint.Time, targetKeypoint.Time, t)
		local newValue = lerp(originalKeypoint.Value, targetKeypoint.Value, t)
		local newEnvelope = lerp(originalKeypoint.Envelope, targetKeypoint.Envelope, t)

		table.insert(newKeypoints, NumberSequenceKeypoint.new(newTime, newValue, newEnvelope))
	end

	return NumberSequence.new(newKeypoints)
end

-- TYPES --
type TweenObject = {
	PlaybackState : Enum.PlaybackState,
	Cancel : (TweenObject) -> TweenObject,
	Play : (TweenObject) -> TweenObject,
	Pause : (TweenObject) -> TweenObject,
	Destroy : (TweenObject) -> nil,
	
	Completed : RBXScriptSignal,
	Paused : RBXScriptSignal,
}

-- MAIN --
local Tween = {}
Tween.__index = Tween

function Tween.new(Object : NumberSequence | ParticleEmitter | Beam, TargetSequence : NumberSequence, Info : TweenInfo, PropertyName : string?) : TweenObject
	local ObjectType : 'NumberSequence' | 'Instance' = 'NumberSequence'
	
	if not Info or typeof(Info) ~= 'TweenInfo' then warn("TweenInfo can't be NIL!") return end
	if typeof(Object) ~= 'NumberSequence' then
		if typeof(Object) ~= 'Instance' or not Object:IsA("ParticleEmitter") and not Object:IsA("Beam") then warn("Invalid object") return end
		if not PropertyName or typeof(PropertyName) ~= 'string' then warn("Invalid property name: ", PropertyName) return end

		ObjectType = "Instance" -- If the property does not exist in the instance, this will likely error
		if typeof(Object[PropertyName]) ~= 'NumberSequence' then warn("Invalid property type: ", typeof(Object)) return end
	end

	local self = setmetatable({}, Tween)
	self.PlaybackState = Enum.PlaybackState.Begin
	
	local completedEvent = Instance.new("BindableEvent")
	local pausedEvent = Instance.new("BindableEvent")
	
	self.Completed = completedEvent.Event
	self.Paused = pausedEvent.Event
	
	local startTick = tick()
	local endTime = startTick + Info.Time
	local pauseTime = tick()
	local activeTask : thread? = nil

	local originalValues = {}

	for i, keypoint : NumberSequenceKeypoint in ipairs(ObjectType == 'NumberSequence' and Object.Keypoints or Object[PropertyName].Keypoints) do
		table.insert(originalValues, keypoint)
	end
	
	local originalSequenceCopy = NumberSequence.new(originalValues)

	function self:Cancel()
		if not self.PlaybackState or self.PlaybackState == Enum.PlaybackState.Completed or self.PlaybackState == Enum.PlaybackState.Cancelled then return self end
		self.PlaybackState = Enum.PlaybackState.Cancelled
		
		return self
	end

	function self:Destroy()
		self:Cancel()
		
		if activeTask then
			task.cancel(activeTask)
			activeTask = nil
		end
		
		pausedEvent:Destroy()
		completedEvent:Destroy()
		self.Completed = nil
		self.Paused = nil
		
		return self
	end

	function self:Play()
		if not self.PlaybackState or self.PlaybackState == Enum.PlaybackState.Completed or self.PlaybackState == Enum.PlaybackState.Cancelled then warn(1) return self end
		
		if self.PlaybackState == Enum.PlaybackState.Paused then
			local currentTime = tick()
			local timePaused = currentTime - pauseTime
			startTick = startTick + timePaused
			endTime = endTime + timePaused
		elseif self.PlaybackState == Enum.PlaybackState.Begin then
			startTick = tick()
			endTime = startTick + Info.Time
		end
		
		self.PlaybackState = Enum.PlaybackState.Playing
		
		if not activeTask then
			activeTask = task.spawn(function()
				while tick() < endTime do
					if tick() >= endTime then break end
					if self.PlaybackState == Enum.PlaybackState.Cancelled then break end

					if self.PlaybackState == Enum.PlaybackState.Paused then
						task.wait()
						continue
					end

					local timePassed = tick() - startTick
					local currentProgress = math.clamp(timePassed / Info.Time, 0, 1)
					currentProgress = TS:GetValue(currentProgress, Info.EasingStyle, Info.EasingDirection)

					local newSequence = getNewValues(currentProgress, originalSequenceCopy, TargetSequence)

					if ObjectType == 'NumberSequence' then
						Object = newSequence
					else
						Object[PropertyName] = newSequence
					end

					task.wait()
				end

				if self.PlaybackState == Enum.PlaybackState.Playing or self.PlaybackState == Enum.PlaybackState.Cancelled then
					self.PlaybackState = Enum.PlaybackState.Completed
					completedEvent:Fire()
				end
			end)
		end

		return self
	end
	
	function self:Pause()
		if self.PlaybackState == Enum.PlaybackState.Playing then
			self.PlaybackState = Enum.PlaybackState.Paused
			pauseTime = tick()
			pausedEvent:Fire()
		end
		
		return self
	end
	
	return self
end

return Tween

Get it here:

NumberSequenceTween.rbxm (3.0 KB)

10 Likes

Just released v3.0

  • You can now tween with color

Hi, can you share a .rbxl or model?

Also did you use any of the previous posters info / mods?

Thanks