I have returned.
so, I was trying to get this script to work with multiple text labels, just for my game to constantly be crashing.
if anyone wants to try and fix it be my guess:
--Licensed under the MIT license
--Made by Powerbow47, refactored using gemini
--[[
=== [Methods] ===
init(holder,soundStorage)
holder should be a ui element like a frame, this determines where subtitles are generated
soundStorage should be a folder with other folders named voiceNames with sound objects inside them, they will be randomly chosen if a voice is used when displaying subtitles
displaySubtitle(text,voiceName)
text is the text you want to display
voiceName should be the name of a folder inside soundStorage
getCurrentSubtitleTask()
=== [Custom rich text] ===
<b>Bold</b>
<s>Small</s>
<c#FF0000>colored text</c> (Hex color code)
<g>Glitching</g>
<h>Shaking</h>
<j>Jumping</j>
<o>Orbit</o>
<r>Rotating</r>
<pX> (Replace X with a number)
]]
local module = {}
--// Services
local TweenService = game:GetService("TweenService")
local RunService = game:GetService("RunService")
local TextService = game:GetService("TextService")
local LocalizationService = game:GetService("LocalizationService")
--// UI Elements & Assets
local holder:Frame
local SoundsStorage:Folder
local characterTemplate = require(script.Char).get()
--// Modules
local SoundManagerModule = require(script.SoundCache)
local soundManager
--// Configuration
local CONFIG = {
-- Timing
PIXELS_PER_SECOND = 200,
READ_CHARS_PER_SECOND = 15,
MIN_DISPLAY_DURATION = 2.5,
MAX_DISPLAY_DURATION = 7.0,
CONSTANT = 1.38,
BASE_SPEED = 600,
MANUAL_DISAPPEAR = true,
-- Animation
APPEAR_INFO = TweenInfo.new(0.1, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
APPEAR_BACK_INFO = TweenInfo.new(0.45, Enum.EasingStyle.Back, Enum.EasingDirection.Out),
DISAPPEAR_INFO = TweenInfo.new(0.75, Enum.EasingStyle.Back, Enum.EasingDirection.Out),
STROKE_APPEAR_INFO = TweenInfo.new(0.2, Enum.EasingStyle.Linear, Enum.EasingDirection.Out),
JUMP_EFFECT = {
COOLDOWN = 0.2,
LERP_ALPHA = 0.05,
ROTATION_DECAY = 0.95,
},
-- Sound
SILENT_LETTERS = {
['"'] = true, ["'"] = true, ["~"] = true, [","] = true, ["."] = true,
["_"] = true, ["?"] = true, ["!"] = true, ["@"] = true, ["["] = true,
["]"] = true, ["("] = true, [")"] = true, ["^"] = true, ["+"] = true,
["-"] = true, ["*"] = true,
},
-- Effects
GLITCHED_CHARACTERS = {"░", "▒", "▓", "█", "▀", "▄", "▙", "▚", "▛", "▜", "▞", "▟", "☐"},
}
local JUMP_CONFIG = CONFIG.JUMP_EFFECT
local textSizes = {
[1] = { --size = 20
[" "] = 5,["!"] = 4,['"'] = 8,["#"] = 12,["$"] = 10,["%"] = 9,["&"] = 12,["'"] = 5,["("] = 5,[")"] = 5,["*"] = 9,
["+"] = 10,[","] = 4,["-"] = 6,["."] = 4,["/"] = 9,["0"] = 11,["1"] = 6,["2"] = 11,["3"] = 11,["4"] = 11,["5"] = 11,
["6"] = 11,["7"] = 11,["8"] = 11,["9"] = 11,["<"] = 11,["="] = 9,[">"] = 11,["?"] = 10,["@"] = 13,["A"] = 12,["B"] = 11,
["C"] = 11,["D"] = 11,["E"] = 10,["F"] = 10,["G"] = 11,["H"] = 11,["I"] = 5,["J"] = 10,["K"] = 11,["L"] = 10,["M"] = 12,
["N"] = 11,["O"] = 11,["P"] = 11,["Q"] = 11,["R"] = 11,["S"] = 10,["T"] = 10,["U"] = 11,["V"] = 11,["W"] = 12,["X"] = 10,
["Y"] = 12,["Z"] = 10,["["] = 5,["]"] = 5,["^"] = 9,["_"] = 11,["a"] = 9,["b"] = 10,["c"] = 9,["d"] = 10,["e"] = 9,
["f"] = 5,["g"] = 10,["h"] = 9,["i"] = 5,["j"] = 5,["k"] = 10,["l"] = 5,["m"] = 15,["n"] = 9,["o"] = 9,["p"] = 10,
["q"] = 10,["r"] = 7,["s"] = 8,["t"] = 6,["u"] = 9,["v"] = 10,["w"] = 12,["x"] = 10,["y"] = 10,["z"] = 8,["{"] = 6,
["|"] = 4,["}"] = 6,["~"] = 11
},
[2] = { --size = 35
[" "] = 8,["!"] = 7,['"'] = 14,["#"] = 21,["$"] = 17,["%"] = 16,["&"] = 20,["'"] = 8,["("] = 9,[")"] = 9,["*"] = 15,
["+"] = 17,[","] = 7,["-"] = 10,["."] = 7,["/"] = 16,["0"] = 19,["1"] = 10,["2"] = 19,["3"] = 18,["4"] = 19,["5"] = 18,
["6"] = 18,["7"] = 18,["8"] = 18,["9"] = 18,["<"] = 19,["="] = 16,[">"] = 19,["?"] = 17,["@"] = 23,["A"] = 20,["B"] = 18,
["C"] = 18,["D"] = 18,["E"] = 17,["F"] = 17,["G"] = 19,["H"] = 18,["I"] = 9,["J"] = 17,["K"] = 18,["L"] = 17,["M"] = 20,
["N"] = 18,["O"] = 19,["P"] = 18,["Q"] = 19,["R"] = 18,["S"] = 17,["T"] = 17,["U"] = 18,["V"] = 18,["W"] = 21,["X"] = 17,
["Y"] = 21,["Z"] = 17,["["] = 9,["]"] = 9,["^"] = 16,["_"] = 18,["a"] = 16,["b"] = 17,["c"] = 15,["d"] = 17,["e"] = 16,
["f"] = 9,["g"] = 17,["h"] = 16,["i"] = 8,["j"] = 8,["k"] = 17,["l"] = 8,["m"] = 26,["n"] = 16,["o"] = 16,["p"] = 17,
["q"] = 17,["r"] = 12,["s"] = 14,["t"] = 10,["u"] = 16,["v"] = 17,["w"] = 21,["x"] = 17,["y"] = 17,["z"] = 14,["{"] = 11,
["|"] = 7,["}"] = 11,["~"] = 19
},
[3] = { --size = 50
[" "] = 12,["!"] = 9,['"'] = 20,["#"] = 30,["$"] = 24,["%"] = 22,["&"] = 28,["'"] = 12,["("] = 13,[")"] = 13,["*"] = 21,
["+"] = 23,[","] = 9,["-"] = 14,["."] = 9,["/"] = 22,["0"] = 26,["1"] = 14,["2"] = 27,["3"] = 26,["4"] = 26,["5"] = 26,
["6"] = 26,["7"] = 26,["8"] = 26,["9"] = 26,["<"] = 27,["="] = 23,[">"] = 27,["?"] = 24,["@"] = 32,["A"] = 28,["B"] = 26,
["C"] = 26,["D"] = 26,["E"] = 24,["F"] = 24,["G"] = 26,["H"] = 26,["I"] = 13,["J"] = 24,["K"] = 26,["L"] = 24,["M"] = 28,
["N"] = 26,["O"] = 26,["P"] = 26,["Q"] = 26,["R"] = 26,["S"] = 24,["T"] = 24,["U"] = 26,["V"] = 26,["W"] = 29,["X"] = 24,
["Y"] = 29,["Z"] = 24,["["] = 13,["]"] = 13,["^"] = 22,["_"] = 26,["a"] = 22,["b"] = 23,["c"] = 21,["d"] = 23,["e"] = 22,
["f"] = 13,["g"] = 23,["h"] = 22,["i"] = 11,["j"] = 11,["k"] = 24,["l"] = 11,["m"] = 36,["n"] = 22,["o"] = 22,["p"] = 23,
["q"] = 23,["r"] = 16,["s"] = 20,["t"] = 14,["u"] = 22,["v"] = 23,["w"] = 30,["x"] = 24,["y"] = 24,["z"] = 20,["{"] = 15,
["|"] = 9,["}"] = 15,["~"] = 27
},
}
--// State Variables
local activeEffects = {}
local activeLabelEffects = {}
local activeEffectConnection = nil
local currentSubtitleTask = nil
local isSubtitleActive = false
local ActiveLabels = {
}
--================------------------------------------------------------------------
--= EFFECTS =
--================------------------------------------------------------------------
-- A dictionary of functions to handle visual effects per frame.
local effectNameMap = {
h = "shake",
g = "glitch",
j = "jump",
r = "rotate",
o = "orbit"
}
local effectHandlers = {
shake = function(label)
label.AnchorPoint = Vector2.new(math.random(30, 40) / 100, math.random(30, 40) / 100)
label.Rotation = math.random(-50, 50) / 10
end,
glitch = function(label)
if math.random(1, 10) == 1 then
label.Text = label:GetAttribute("OriginalChar")
else
label.Text = CONFIG.GLITCHED_CHARACTERS[math.random(#CONFIG.GLITCHED_CHARACTERS)]
end
end,
jump = function(label)
-- Lazily create state attributes on the label itself
local lastJumpTime = label:GetAttribute("LastJumpTime") or 0
if (os.clock() - lastJumpTime) > JUMP_CONFIG.COOLDOWN then
label:SetAttribute("LastJumpTime", os.clock())
if math.random(0, 1) == 0 then
label.AnchorPoint = Vector2.new(math.random(40, 60) / 100, math.random(40, 60) / 100)
label.Rotation = math.random(-50, 50) / 10
end
else
label.AnchorPoint = label.AnchorPoint:Lerp(Vector2.new(0.5, 0.5), JUMP_CONFIG.LERP_ALPHA)
label.Rotation *= JUMP_CONFIG.ROTATION_DECAY
end
end,
rotate = function(label)
local startTime = label:GetAttribute("StartTime")
local index = label:GetAttribute("EffectIndex")
local timeElapsed = (os.clock() - startTime + index / 80) * 8
label.Rotation = math.sin(timeElapsed) * 10
end,
orbit = function(label)
local startTime = label:GetAttribute("StartTime")
local index = label:GetAttribute("EffectIndex")
local timeElapsed = (os.clock() - startTime + index / 80) * 8
label.AnchorPoint = Vector2.new(.5+(math.sin(timeElapsed) + 1) / 12, .5+(math.cos(timeElapsed) + 1) / 12)
end,
}
function module.updateEffects()
-- Only disconnect if no subtitle is active AND there are no effects left.
-- This prevents the connection from destroying itself before effects are added.
--[[if #activeEffects == 0 and not isSubtitleActive then
if activeEffectConnection then
activeEffectConnection:Disconnect()
activeEffectConnection = nil
end
return
end]]
for _, Slot in ipairs(ActiveLabels) do
local Characters = Slot.Characters
for _, Character in ipairs(Characters) do
local effect = Character:GetAttribute("Effect")
if effect then
if effectHandlers[effect] then
effectHandlers[effect](Character)
end
end
end
end
end
--================------------------------------------------------------------------
--= PARSING =
--================------------------------------------------------------------------
-- Parses the entire markup string at once and returns a list of character objects
function parseMarkup(text: string)
local characters = {}
local totalWidth = 0
local state = {
-- We now use a size index (1=small, 2=normal, 3=big) for the lookup table
sizeIndex = 2, -- Start at normal size
color = characterTemplate.TextColor3,
effects = {},
}
local pattern = "<(.-)>"
local lastIndex = 1
for tagContent in text:gmatch(pattern) do
local tagStart = text:find("<" .. tagContent .. ">", lastIndex, true)
-- 1. Process plain text before the tag
local plainText = text:sub(lastIndex, tagStart - 1)
for char in plainText:gmatch(".") do
local charWidth = (textSizes[state.sizeIndex] and textSizes[state.sizeIndex][char]) or 15
local charHeight = (state.sizeIndex == 1 and 20 or state.sizeIndex == 3 and 50 or 35)
local charEffects = {}
for k, v in pairs(state.effects) do if v then charEffects[k] = v end end
table.insert(characters, {
Text = char,
Width = charWidth,
Height = charHeight,
Color = state.color,
Effects = charEffects,
})
totalWidth += charWidth
end
-- 2. Process the tag itself
local tagType = tagContent:sub(1, 1)
local isClosing = (tagType == "/")
if isClosing then tagType = tagContent:sub(2, 2) end
-- UPDATE THE SIZE INDEX
if tagType == "b" then
state.sizeIndex = isClosing and 2 or 3 -- big
elseif tagType == "s" then
state.sizeIndex = isClosing and 2 or 1 -- small
elseif tagType == "c" then
if isClosing then
state.color = characterTemplate.TextColor3
else
state.color = Color3.fromHex(tagContent:sub(2))
end
elseif effectNameMap[tagType] then
state.effects[effectNameMap[tagType]] = not isClosing
end
lastIndex = tagStart + #tagContent + 2
end
-- 3. Process any remaining plain text after the last tag
local remainingText = text:sub(lastIndex)
for char in remainingText:gmatch(".") do
local charWidth = (textSizes[state.sizeIndex] and textSizes[state.sizeIndex][char]) or 15
local charHeight = (state.sizeIndex == 1 and 20 or state.sizeIndex == 3 and 50 or 35)
local charEffects = {}
for k, v in pairs(state.effects) do if v then charEffects[k] = v end end
table.insert(characters, {
Text = char,
Width = charWidth,
Height = charHeight,
Color = state.color,
Effects = charEffects,
})
totalWidth += charWidth
end
return characters, totalWidth
end
--================------------------------------------------------------------------
--= MAIN LOGIC =
--================------------------------------------------------------------------
function playRandomSound()
if soundManager then
soundManager:PlaySound()
end
end
function module.disappearAnimation(HolderOverright)
local holder = HolderOverright or holder
for _, v in ipairs(holder:GetChildren()) do
if v:IsA("TextLabel") then
TweenService:Create(v, CONFIG.DISAPPEAR_INFO, {TextTransparency = 1}):Play()
TweenService:Create(v.UIStroke, CONFIG.APPEAR_INFO, {Transparency = 1}):Play()
end
end
task.wait(CONFIG.DISAPPEAR_INFO.Time)
table.clear(activeEffects)
holder:ClearAllChildren()
end
-- AnimateText
function module.AnimateText(TextLabel: TextLabel)
if TextLabel == nil then
warn("TextLabel missing")
return
end
task.spawn(function()
local ListToRegister = SetUpTextLabel(TextLabel)
local Number = #ActiveLabels + 1
ActiveLabels[Number] = ListToRegister
local Connection
Connection = TextLabel.Destroying:Connect(function()
ActiveLabels[Number] = nil
Connection:Disconnect()
end)
end)
end
function SetUpTextLabel(TextLabel)
local text = TextLabel.Text
local holder = TextLabel
TextLabel.Text = ""
local readChars = 0
local segments = {}
local pattern = "<p([%d%.]+)>"
local lastEnd = 1
local ListToRegister = {
Label = holder,
Characters = {},
}
for num, newEnd in text:gmatch(pattern .. "()") do
local segmentText = text:sub(lastEnd, newEnd - #num - 4)
table.insert(segments, {Text = segmentText, Pause = tonumber(num)})
lastEnd = newEnd
end
if lastEnd <= #text then
table.insert(segments, {Text = text:sub(lastEnd), Pause = 0})
end
local basePPS = CONFIG.BASE_SPEED
local pps = CONFIG.PIXELS_PER_SECOND
local dynamicConstant = pps > basePPS and CONFIG.CONSTANT * (pps / basePPS) or (CONFIG.CONSTANT * ((basePPS - pps) / basePPS))
-- 3. Animate each segment
local previousLabel = nil
local allLabels = {}
local Effects = {}
for _, segment in ipairs(segments) do
local characters, totalWidth = parseMarkup(segment.Text)
readChars += #characters
local fullTextWidth = 0
for _, lbl in ipairs(allLabels) do fullTextWidth += lbl.Size.X.Offset end
fullTextWidth += totalWidth
local lastLabelStateBeforeShift = nil
if #allLabels > 0 then
local lastLabel = allLabels[#allLabels]
lastLabelStateBeforeShift = {
Position = lastLabel.Position,
Size = lastLabel.Size
}
end
local currentX = (holder.AbsoluteSize.X - fullTextWidth) / 2
for _, lbl in ipairs(allLabels) do
local newPos = UDim2.fromScale((currentX + lbl.Size.X.Offset / 2) / holder.AbsoluteSize.X, 0.5)
local distanceToMove = math.abs(newPos.X.Scale - lbl.Position.X.Scale) * holder.AbsoluteSize.X
local shiftDuration = distanceToMove / CONFIG.PIXELS_PER_SECOND * dynamicConstant
TweenService:Create(lbl, TweenInfo.new(shiftDuration, Enum.EasingStyle.Linear), {Position = newPos}):Play()
currentX += lbl.Size.X.Offset
end
for i, charData in ipairs(characters) do
local label = TextLabel:Clone()
label.Text = charData.Text
label.TextColor3 = charData.Color
label.Size = UDim2.fromOffset(charData.Width, charData.Height)
label.Visible = true
label.Name = "Char_"..(#allLabels + 1)
label.Parent = holder
table.insert(allLabels, label)
label:SetAttribute("OriginalChar", charData.Text)
if charData.Effects then
label:SetAttribute("StartTime", os.clock())
label:SetAttribute("EffectIndex", #activeEffects + 1)
for effectName, _ in charData.Effects do
label:SetAttribute("Effect", effectName) -- Use the instance as the key
end
end
local startX
if i == 1 and lastLabelStateBeforeShift then
-- This is the first char of a new segment.
-- Use the CAPTURED state from before the old text started moving.
-- This makes the new char emerge from where the old text WAS, fixing the gap.
startX = lastLabelStateBeforeShift.Position.X.Scale * holder.AbsoluteSize.X + lastLabelStateBeforeShift.Size.X.Offset / 2 + charData.Width / 2
else
-- This is the standard "caterpillar" case for all other characters.
-- We use the live position of the previous character.
startX = previousLabel and (previousLabel.Position.X.Scale * holder.AbsoluteSize.X + previousLabel.Size.X.Offset / 2 + charData.Width / 2) or holder.AbsoluteSize.X / 2
end
local finalX = currentX + charData.Width / 2
label.Position = UDim2.fromScale(startX / holder.AbsoluteSize.X, 0.5)
label.Size = UDim2.fromOffset(charData.Width, charData.Height / 4)
label.Rotation = math.random(15, 35) * (math.random(0, 1) == 1 and 1 or -1)
local travelDuration = math.abs(finalX - startX) / CONFIG.PIXELS_PER_SECOND * dynamicConstant
TweenService:Create(label, TweenInfo.new(travelDuration, Enum.EasingStyle.Linear), {Position = UDim2.fromScale(finalX / holder.AbsoluteSize.X, 0.5)}):Play()
TweenService:Create(label, CONFIG.APPEAR_INFO, {TextTransparency = 0}):Play()
TweenService:Create(label.UIStroke, CONFIG.STROKE_APPEAR_INFO, {Transparency = 0}):Play()
TweenService:Create(label, CONFIG.APPEAR_BACK_INFO, {Size = UDim2.fromOffset(charData.Width, charData.Height), Rotation = 0}):Play()
if not CONFIG.SILENT_LETTERS[charData.Text] then playRandomSound() end
task.wait((charData.Width / CONFIG.PIXELS_PER_SECOND) / 2)
previousLabel = label
currentX += charData.Width
end
task.wait(segment.Pause)
end
ListToRegister.Characters = allLabels
return ListToRegister
end
-- This is the main animation function, designed to be run in its own coroutine.
function animateSubtitle(text: string, cancelToken: {cancelled: boolean, skipped: boolean}, TextLabelOverright: TextLabel)
-- 1. Teardown & Setup
local holder = TextLabelOverright or holder
if TextLabelOverright then
text = TextLabelOverright.Text
TextLabelOverright.Text = ""
end
for _, label in ipairs(holder:GetChildren()) do
if activeLabelEffects[label] then
activeLabelEffects[label] = nil -- Dereference to allow garbage collection
end
end
--table.clear(activeEffects)
holder:ClearAllChildren()
local readChars = 0
-- 2. Split by pause tags to create segments for animation
SetUpTextLabel(holder, cancelToken)
end
function module.init(gotHolder: GuiBase,gotSoundsStorage: Folder?)
holder = gotHolder
SoundsStorage = gotSoundsStorage
if SoundsStorage then
local soundsFolder = Instance.new("Folder")
soundsFolder.Name = "Sounds"
soundsFolder.Parent = script
soundManager = SoundManagerModule.new(soundsFolder,SoundsStorage)
end
end
function module.displaySubtitle(text: string,voiceName: string?)
if voiceName then
if soundManager then
soundManager:SetVoice(voiceName)
else
warn("No sound storage given when module initialized.")
end
else
soundManager:SetVoice()
end
-- Cancel the previous subtitle task if it exists
if currentSubtitleTask then
currentSubtitleTask.cancelToken.cancelled = true
task.cancel(currentSubtitleTask.thread)
end
isSubtitleActive = true -- Set flag: a subtitle process has begun
-- Start the effect handler if it's not already running
-- Create a new task for the new subtitle
local cancelToken = { cancelled = false, skipped = false }
local thread = task.spawn(function()
-- Run the main animation
animateSubtitle(text, cancelToken)
-- IMPORTANT: Once the animation coroutine is completely finished,
-- mark it as inactive. This tells updateEffects it can now safely clean up.
isSubtitleActive = false
end)
currentSubtitleTask = {
thread = thread,
cancelToken = cancelToken,
}
end
function module.getCurrentSubtitleTask():{thread:thread,cancelToken:{cancelled:boolean,skipped:boolean}}
return currentSubtitleTask
end
return module