Scripting Lua

Debounce: por que a sua armadilha tira a vida inteira do jogador de uma vez

O evento Touched dispara várias vezes em sequência por causa de colisões físicas sutis. Sem debounce, uma armadilha de 10 de dano tira muito mais que 10 — e o mesmo vale para som, coletável e recarga.

por Gustavo Cantino9 min de leituraIniciante

Produzido com IA a partir das fontes citadas abaixo, e conferido por checagens automáticas contra essas mesmas fontes. Nenhuma pessoa leu este texto antes da publicação. Política Editorial

Placa tipográfica com a categoria Scripting Lua em destaque, o nível iniciante abaixo, e um campo de barras verticais verde-limão sobre fundo escuro.

Você faz uma armadilha que tira 10 de vida. O jogador pisa nela e morre na hora, com a barra inteira indo a zero. O código está certo, o número está certo, e o resultado está errado.

É o primeiro encontro de quase todo mundo com o problema que o debounce resolve, e a causa não está no seu script — está na física.

O problema

A definição oficial é direta: debounce é a técnica que impede uma função de rodar vezes demais ou uma entrada de disparar várias vezes.

O exemplo que a documentação usa é exatamente o da armadilha: uma peça que causa 10 de dano ao ser tocada. A implementação ingênua conecta uma função ao Touched e subtrai 10 da vida do Humanoid. Lê bem, parece certo.

E aí vem a frase que explica o mistério: o teste mostra que o evento Touched dispara várias vezes em rápida sucessão, por causa de colisões físicas sutis.

Isso não é bug do motor. Um personagem em cima de uma peça não fica parado — ele oscila em frações de stud, cada perna toca em momentos diferentes, a colisão se resolve e se refaz. Cada um desses micro-contatos é um Touched. O seu dano de 10 vira 10 vezes o número de contatos, e o número de contatos não é previsível.

A saída, na própria documentação, é um sistema de debounce que impõe um período de recarga no dano, por meio de um atributo da instância.

Passo a passo

O padrão tem sempre a mesma forma, em três passos: conferir se já está em recarga, marcar que está, fazer o trabalho, e desmarcar depois do tempo.

1. Escolha onde guardar o estado. A documentação usa um atributo da instância, e a escolha tem uma vantagem que não é óbvia: atributo aparece no Explorer durante o teste. Dá para ver o estado do debounce mudando na tela, sem print nenhum.

2. Defina o tempo. O exemplo da armadilha usa um tempo de reinício de um segundo. Não há número universal — o tempo certo é o que faz o dano acontecer uma vez por "pisada", e isso depende do seu jogo.

3. Aplique a três famílias de caso. A documentação percorre três, e elas cobrem quase tudo:

Colisão e dano. O caso da armadilha. O atributo entra em true, o dano acontece, espera-se o tempo, o atributo volta a false.

Som. Debounce também serve para efeito sonoro — som ao dois objetos colidirem, som ao clicar num botão. O motivo é mecânico: chamar o Play() do som recomeça a reprodução do início da faixa. Sem debounce, o som reinicia a cada disparo e vira um estalo.

E aqui a documentação dá uma solução mais elegante que um atributo: para evitar sobreposição, o debounce usa a propriedade IsPlaying do objeto Sound. O estado já existe — não é preciso inventar outro.

Coletável com recarga. Jogos costumam ter coletáveis no mundo 3D, como kits médicos e pacotes de munição. Se o coletável deve permanecer no mundo para ser pego de novo, é preciso um tempo de recarga antes de ele reativar.

Assim como na detecção de colisão, o estado é gerenciado por um atributo da instância. O exemplo do coletável de vida usa cinco segundos de recarga e devolve 25 de vida. E acrescenta uma coisa que vale copiar: durante a recarga a peça fica semitransparente, o que torna o estado visível para o jogador.

4. Deixe o estado visível. O exemplo do coletável faz algo que vale copiar em quase todo caso: além de bloquear, ele mostra. A peça semitransparente durante a recarga responde, sem texto nenhum, a pergunta que o jogador faria — "por que não funcionou?".

Isso separa dois problemas que se parecem na tela. Sem retorno visual, um coletável em recarga e um coletável quebrado são indistinguíveis para quem está jogando, e o jogador conclui a segunda hipótese. Com retorno, ele espera.

O mesmo princípio vale para recarga de arma, porta que está fechando e botão que já foi apertado: o estado precisa ser legível de fora, não só verdadeiro por dentro.

Artefato

O módulo abaixo separa duas coisas que o exemplo oficial mantém juntas, e a separação é uma decisão de jogo, não de implementação.

O exemplo guarda o estado num atributo da peça. Isso resolve o caso do coletável perfeitamente: o objeto é que fica indisponível. Mas aplicado a uma armadilha, produz um comportamento errado assim que duas pessoas pisam nela — enquanto um jogador está em recarga, o outro não toma dano nenhum, porque a recarga é da peça.

Então: porPeca para quando o objeto fica indisponível — coletável, alavanca, porta. porJogador para quando o que se limita é a pessoa — dano, tiro, ação.

O terceiro uso do exemplo, o som, não usa o módulo de propósito: o IsPlaying já é a fonte da verdade, e criar um atributo ao lado seria manter dois estados para a mesma coisa.

Vale registrar um detalhe de vocabulário que confunde quem procura por isso: "debounce" vem da eletrônica, do ruído mecânico de um contato que oscila ao fechar. O paralelo é exato — a colisão física no Roblox produz o mesmo tipo de ruído, e a solução é a mesma: ignorar o que vier dentro de uma janela de tempo depois do primeiro sinal válido.

Erro comum

O debounce de peça numa armadilha. É o erro descrito acima, e ele é traiçoeiro porque o teste de uma pessoa só passa. Você testa sozinho no Studio, funciona, publica. Com dois jogadores, a armadilha vira loteria.

O task.wait bloqueando dentro do handler. O padrão do exemplo usa espera dentro da função conectada, e isso é aceitável ali. Mas em código que faz mais coisas, esperar dentro do handler segura aquela execução inteira — e como cada Touched roda em sua própria thread, o resultado é um acúmulo de threads esperando. O artefato usa task.delay para desmarcar, o que não segura nada.

Esquecer de desmarcar quando a peça some. Se a peça for destruída durante a recarga, a função que ia desmarcar ainda roda. Com atributo, escrever numa peça destruída é problema; com tabela indexada por jogador, o estado fica pendurado para sempre. As duas correções estão no artefato — conferir o Parent antes de escrever, e limpar no PlayerRemoving.

Usar debounce onde o problema é outro. Se o coletável deve sumir quando pego, não há debounce nenhum: o objeto é destruído, e o Touched não dispara mais porque não existe mais peça. Debounce é para o que permanece.

Uma variável única para tudo. Um local debounce = false no topo de um script que atende vários objetos faz todos compartilharem o mesmo estado. É o mesmo erro do escopo, com outra roupa — e é mais difícil de ver, porque a variável parece local.

Há ainda uma decisão de arquitetura que vale tomar cedo: onde o debounce vive. Espalhado pelos scripts, cada objeto tem a própria variante, e a terceira delas já estará errada. Num módulo, como o do artefato, a regra é uma só e a escolha de escopo fica explícita na chamada — quem lê o código vê se aquilo limita a peça ou a pessoa, sem precisar descobrir.

Próximo passo

O conceito de recarga se estende além do que a documentação usa como exemplo, e ela mesma diz isso: o mesmo conceito serve para outros casos, como limitar a frequência com que o jogador dispara foguetes.

Essa frase é a ponte para o uso mais importante do padrão: ele deixa de ser um conserto de física e vira regra de jogo. Tempo de recarga de arma, intervalo entre habilidades, limite de quantas vezes se abre um baú — tudo é debounce com outro nome, e tudo é decisão de balanceamento.

Quando o debounce vira regra de jogo, duas coisas mudam. O tempo passa a ser um número que se ajusta com frequência, então ele deve morar num lugar fácil de mexer — um atributo, um módulo de configuração — e não escrito dentro da função. E o estado precisa ser visível ao jogador: recarga que o jogador não vê é recarga que ele interpreta como o jogo não estar respondendo. A peça semitransparente do exemplo oficial é a versão mais simples disso, e funciona.

O exercício para hoje: procure no seu código todo Touched que mexe em vida, moeda ou inventário. Cada um sem debounce é um multiplicador aleatório esperando dois jogadores no mesmo lugar.

Fontes

Como corrigimos erros

Artefato pronto pra usar

Debounce reutilizável, com escopo por peça e por jogador (ReplicatedStorage ou ServerStorage)
lua
-- ModuleScript "Debounce"
--
-- O exemplo oficial guarda o estado num ATRIBUTO da peça, e isso resolve
-- o caso de uma armadilha. Ele para de resolver no momento em que duas
-- pessoas pisam na mesma armadilha: o atributo é da PEÇA, então enquanto
-- um jogador está em recarga, o outro não toma dano nenhum.
--
-- Este módulo separa os dois escopos, porque a escolha entre eles é uma
-- decisão de JOGO, não de implementação:
--
--   porPeca     -> a peça inteira entra em recarga. Coletável, alavanca,
--                  porta: o objeto é que fica indisponível.
--   porJogador  -> cada jogador tem a própria recarga. Armadilha, dano,
--                  tiro: o que se limita é a pessoa.

local Debounce = {}

-- ── escopo POR PEÇA (o do exemplo oficial) ──────────────────────────
-- Usa atributo mesmo: fica visível no Explorer durante o Play, o que
-- torna o estado inspecionável sem print nenhum.
function Debounce.porPeca(peca, chave, segundos)
	if peca:GetAttribute(chave) then
		return false
	end
	peca:SetAttribute(chave, true)
	task.delay(segundos, function()
		if peca.Parent then
			peca:SetAttribute(chave, false)
		end
	end)
	return true
end

-- ── escopo POR JOGADOR ──────────────────────────────────────────────
local ultimo = {}

function Debounce.porJogador(player, chave, segundos)
	local agora = os.clock()
	ultimo[player] = ultimo[player] or {}
	local anterior = ultimo[player][chave]
	if anterior and (agora - anterior) < segundos then
		return false
	end
	ultimo[player][chave] = agora
	return true
end

game:GetService("Players").PlayerRemoving:Connect(function(player)
	ultimo[player] = nil
end)

return Debounce

--[[
═══════════════════════════════════════════════════════════════════════
OS TRÊS USOS, com o escopo certo em cada um
═══════════════════════════════════════════════════════════════════════

-- 1. ARMADILHA: por JOGADOR. Dois jogadores em cima dela tomam dano
--    independentemente, que é o comportamento que o jogador espera.
local Players = game:GetService("Players")
local armadilha = script.Parent

armadilha.Touched:Connect(function(outraParte)
    local humanoid = outraParte.Parent:FindFirstChildWhichIsA("Humanoid")
    if not humanoid or humanoid.Health <= 0 then return end
    local player = Players:GetPlayerFromCharacter(outraParte.Parent)
    if not player then return end

    if Debounce.porJogador(player, "armadilha_" .. armadilha:GetDebugId(), 1) then
        humanoid.Health -= 10
    end
end)

-- 2. COLETÁVEL COM RECARGA: por PEÇA, com o estado VISÍVEL.
local kit = script.Parent
kit.Anchored = true
kit.CanCollide = false

kit.Touched:Connect(function(outraParte)
    local humanoid = outraParte.Parent:FindFirstChildWhichIsA("Humanoid")
    if not humanoid then return end

    if Debounce.porPeca(kit, "CoolingDown", 5) then
        humanoid.Health += 25
        kit.Transparency = 0.75
        task.delay(5, function()
            if kit.Parent then kit.Transparency = 0 end
        end)
    end
end)

-- 3. SOM: não precisa deste módulo. O próprio objeto já carrega o
--    estado, e IsPlaying é a fonte da verdade -- inventar um atributo
--    ao lado criaria dois estados para a mesma coisa.
local projetil = script.Parent
projetil.Touched:Connect(function()
    local som = projetil:FindFirstChild("Impact")
    if som and not som.IsPlaying then
        som:Play()
    end
end)
]]
Neste artigo

Continue por aqui