Join the RoClicks Discord
RoClicks

Feedback.luau

Luau, 84 lines from Sound and VFX overview

Download Feedback.luau
--!strict
-- Feedback.luau: every important action gets ONE sound + ONE particle burst + ONE float text.
-- Call sites say what happened; this table decides how it feels, so moments can't drift or double up.
--
--   Feedback.moment("collect", buildingPos, "+$120")
--   Feedback.moment("trained", barracksPos, "+5 Riflemen")
--
-- Each moment has ONE owner. If a second system also reacts, give it a different, smaller cue.
-- Every sound and burst name below must exist in your registry / template folder (unknown names warn).

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

local Sound = require(script.Parent:WaitForChild("Sound")) -- SoundRegistry.luau
local FX = require(script.Parent:WaitForChild("FX"))       -- FXPool.luau

type Moment = { sound: string?, burst: string?, scale: number?, color: Color3?, shake: number? }

local MOMENTS: { [string]: Moment } = {
	collect  = { sound = "cash_collect", burst = "collect", color = Color3.fromRGB(255, 214, 80) },
	purchase = { sound = "ui_purchase", burst = "sparkle", color = Color3.fromRGB(255, 255, 255) },
	build    = { sound = "build_complete", burst = "build", color = Color3.fromRGB(255, 255, 255), shake = 0.15 },
	upgrade  = { sound = "building_upgrade", burst = "levelup", color = Color3.fromRGB(120, 220, 255) },
	levelup  = { sound = "ui_levelup", burst = "levelup", scale = 1.6, color = Color3.fromRGB(255, 220, 90), shake = 0.3 },
	trained  = { sound = "train_complete", burst = "levelup", scale = 0.8, color = Color3.fromRGB(140, 255, 150) },
	reward   = { sound = "ui_reward", burst = "sparkle", color = Color3.fromRGB(200, 140, 255) },
	error    = { sound = "ui_error", color = Color3.fromRGB(255, 90, 90) },
}

local Feedback = {}
Feedback.MOMENTS = MOMENTS
Feedback.onShake = nil :: ((amount: number) -> ())? -- hook to your camera module; never shake for ambient events

local function floatText(text: string, pos: Vector3, color: Color3)
	local anchor = Instance.new("Part")
	anchor.Anchored, anchor.CanCollide, anchor.CanQuery, anchor.Transparency = true, false, false, 1
	anchor.Size = Vector3.one * 0.1
	anchor.CFrame = CFrame.new(pos)
	local gui = Instance.new("BillboardGui")
	gui.Size = UDim2.fromOffset(220, 50)
	gui.AlwaysOnTop = true
	gui.LightInfluence = 0
	gui.Parent = anchor
	local label = Instance.new("TextLabel")
	label.Size = UDim2.fromScale(1, 1)
	label.BackgroundTransparency = 1
	label.Font = Enum.Font.GothamBlack
	label.TextScaled = true
	label.Text = text
	label.TextColor3 = color
	label.TextStrokeTransparency = 0.2
	label.Parent = gui
	anchor.Parent = Workspace
	local info = TweenInfo.new(1.1, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
	TweenService:Create(gui, info, { StudsOffsetWorldSpace = Vector3.new(0, 4, 0) }):Play()
	TweenService:Create(label, TweenInfo.new(0.4, Enum.EasingStyle.Linear, Enum.EasingDirection.In, 0, false, 0.7),
		{ TextTransparency = 1, TextStrokeTransparency = 1 }):Play()
	task.delay(1.2, function() anchor:Destroy() end)
end

function Feedback.moment(kind: string, pos: Vector3, text: string?, opts: { pitch: number? }?)
	local m = MOMENTS[kind]
	if not m then
		warn("[Feedback] unknown moment " .. kind) -- never fail silently
		return
	end
	if m.sound then Sound.play(m.sound, pos, opts) end
	if m.burst then FX.burst(m.burst, pos, { scale = m.scale or 1, cull = false }) end
	if text then floatText(text, pos + Vector3.new(0, 2, 0), m.color or Color3.new(1, 1, 1)) end
	if m.shake and Feedback.onShake then Feedback.onShake(m.shake) end
end

-- A run of the same moment rises in pitch (coins, land claims): call with i = 1..n.
function Feedback.run(kind: string, positions: { Vector3 }, maxCount: number?)
	local n = math.min(#positions, maxCount or 5)
	for i = 1, n do
		task.delay((i - 1) * 0.08, function()
			Feedback.moment(kind, positions[i], nil, { pitch = 0.9 + 0.3 * (i - 1) / math.max(1, n - 1) })
		end)
	end
end

return Feedback