Um jogo pode estar correto e mesmo assim parecer injusto: o pulo que não saiu, o livro que "claramente" encostou nas mãos e não contou, a traça que acertou de longe. Este artigo trata das três coisas que decidem essa sensação: como a entrada chega ao jogo, como os corpos se movem e como as colisões são detectadas. Tudo isso se aplica na "Biblioteca em Apuros".
Ações, não teclas
No protótipo MonoGame, o código perguntava pela tecla Keys.Left. Isso tem três problemas: o jogador não pode trocar as teclas, cada dispositivo (teclado, controle, toque) exige seu próprio código, e o significado ("mover para a esquerda") fica misturado com o detalhe ("seta esquerda").
O Godot separa as duas coisas com o Mapa de Entrada (Projeto → Configurações do Projeto → Mapa de Entrada). O jogo define ações com nome, e cada ação recebe um ou mais eventos físicos:
| Ação | Teclado | Controle |
|---|---|---|
mover_esquerda |
A, seta esquerda | analógico esquerdo para a esquerda, direcional esquerdo |
mover_direita |
D, seta direita | analógico esquerdo para a direita, direcional direito |
pular |
Espaço, W, seta para cima | botão A (Xbox) / Cruz (PlayStation) |
pausar |
Esc, P | Start |
O código só conhece as ações:
var direcao = Input.GetAxis(MoverEsquerda, MoverDireita); // −1 a 1, analógico ou digital
if (Input.IsActionJustPressed(Pular)) { /* ... */ }
GetAxis combina as duas ações num valor contínuo: com teclado, −1, 0 ou 1; com analógico, qualquer valor intermediário, já com a zona morta do exercício 5 de "Um game loop do zero com MonoGame" aplicada por ação. Trocar teclas passa a ser editar o mapa, inclusive em tempo de execução, com InputMap.ActionEraseEvents e InputMap.ActionAddEvent, o que permite uma tela de configuração de controles. Acessibilidade em jogos começa aqui: jogadores com mobilidade reduzida muitas vezes dependem de poder escolher as teclas.
Quem recebe a entrada primeiro
Além de consultar o estado em _PhysicsProcess, o Godot entrega eventos de entrada pela árvore, em ordem:
_Input(InputEvent)em todos os nós.- A interface gráfica (botões, campos de texto) consome o que for dela.
_UnhandledInput(InputEvent)recebe o que ninguém consumiu.
A pausa é tratada em _UnhandledInput. Se o jogador está digitando o nome no campo de recorde e aperta P, o campo consome a tecla, e o jogo não pausa no meio da digitação:
public override void _UnhandledInput(InputEvent evento)
{
if (evento.IsActionPressed(Pausar))
{
GetTree().Paused = !GetTree().Paused;
GetViewport().SetInputAsHandled();
}
}
GetTree().Paused congela todos os nós, exceto os configurados para continuar processando durante a pausa (a propriedade ProcessMode = Always), como o próprio menu de pausa.
O pulo
Na "Biblioteca em Apuros", a bibliotecária ganha um pulo: livros raros caem mais rápido e às vezes só podem ser alcançados no ar. Um pulo "de manual" é simples:
if (IsOnFloor() && Input.IsActionJustPressed(Pular))
velocidade.Y = -ForcaDoPulo;
E ele é frustrante. Jogadores reclamam que o pulo "às vezes não sai", e têm razão, por dois motivos que o código acima ignora.
O jogador aperta o botão alguns quadros antes de tocar o chão. Para um humano, "já estou pousando" e "já pousei" são o mesmo momento. Para o código, com 60 passos por segundo, apertar 3 quadros (50 ms) antes do pouso é apertar no ar, e o pulo é descartado.
O jogador aperta alguns quadros depois de sair de uma borda. Correndo por uma prateleira, ele aperta pular exatamente quando vê a borda, e o personagem já está um ou dois quadros no ar. O pulo é negado.
As soluções têm nomes no vocabulário de design de jogos: buffer de pulo e tempo coiote (em homenagem ao Coiote dos desenhos animados, que continua correndo no ar depois de sair do penhasco, até olhar para baixo):
public partial class Bibliotecaria : CharacterBody2D
{
[Export] public float VelocidadeMaxima { get; set; } = 180f;
[Export] public float Aceleracao { get; set; } = 1400f;
[Export] public float Atrito { get; set; } = 1800f;
[Export] public float ForcaDoPulo { get; set; } = 330f;
[Export] public float TempoCoiote { get; set; } = 0.10f;
[Export] public float BufferDoPulo { get; set; } = 0.12f;
[Export] public float MultiplicadorDeQueda { get; set; } = 1.6f;
private static readonly StringName Esquerda = "mover_esquerda";
private static readonly StringName Direita = "mover_direita";
private static readonly StringName Pular = "pular";
private readonly float gravidade =
ProjectSettings.GetSetting("physics/2d/default_gravity").AsSingle();
private float coiote;
private float buffer;
public override void _PhysicsProcess(double delta)
{
var dt = (float)delta;
var v = Velocity;
// 1. tempo coiote: renovado enquanto está no chão, consumido no ar
if (IsOnFloor()) coiote = TempoCoiote;
else coiote -= dt;
// 2. buffer: o pedido de pulo vale por alguns décimos de segundo
if (Input.IsActionJustPressed(Pular)) buffer = BufferDoPulo;
else buffer -= dt;
// 3. pula se houver pedido recente e chão recente
if (buffer > 0 && coiote > 0)
{
v.Y = -ForcaDoPulo;
buffer = 0;
coiote = 0;
}
// 4. altura variável: soltar o botão na subida corta o pulo
if (Input.IsActionJustReleased(Pular) && v.Y < 0)
v.Y *= 0.5f;
// 5. gravidade, mais forte na descida
if (!IsOnFloor())
v.Y += gravidade * (v.Y > 0 ? MultiplicadorDeQueda : 1f) * dt;
// 6. horizontal com aceleração e atrito
var direcao = Input.GetAxis(Esquerda, Direita);
v.X = direcao != 0
? Mathf.MoveToward(v.X, direcao * VelocidadeMaxima, Aceleracao * dt)
: Mathf.MoveToward(v.X, 0, Atrito * dt);
Velocity = v;
MoveAndSlide();
}
}
Cada linha numerada é uma decisão de sensação de jogo, comum em jogos de plataforma:
- Tempo coiote: durante 0,1 s depois de sair do chão, a bibliotecária ainda pode pular.
- Buffer de pulo: um aperto até 0,12 s antes de pousar é guardado e executado no pouso.
- Os dois se combinam na mesma condição, e ambos são zerados ao pular, para que um único aperto não gere dois pulos.
- Altura variável: segurar o botão dá o pulo alto, e um toque curto dá um pulo baixo. O jogador ganha controle fino com um único botão.
- Queda mais pesada: a gravidade na descida é 60% maior. Um pulo com a mesma gravidade na subida e na descida parece "flutuante". A queda mais rápida dá peso ao personagem.
- Aceleração e atrito em vez de velocidade instantânea: a bibliotecária leva alguns quadros para atingir a velocidade máxima e para parar.
MoveTowardaproxima um valor de outro sem ultrapassá-lo. Valores altos dão controle preciso, e valores baixos dão a sensação de gelo.
Todos esses números são exportados. O ajuste fino da sensação de controle é trabalho de horas testando no editor, e deve ser feito por quem joga, e não por quem compila.
Quatro tipos de corpo
O Godot oferece quatro nós de física 2D. Escolher o certo para cada entidade evita metade dos problemas:
| Nó | Quem move | Colide? | Uso na Biblioteca em Apuros |
|---|---|---|---|
StaticBody2D |
ninguém (ou código, raramente) | sim, como obstáculo | chão, prateleiras |
CharacterBody2D |
o seu código, via MoveAndSlide |
sim: para e desliza em obstáculos | a bibliotecária |
RigidBody2D |
o motor de física: forças, gravidade, impulsos | sim, com resposta física | livros perdidos que quicam no chão |
Area2D |
o seu código, pela posição | não bloqueia: só detecta sobreposição | livros caindo, traças, as mãos, zonas |
CharacterBody2D com MoveAndSlide é o corpo de personagens controlados. O código define a Velocity e chama MoveAndSlide(), que move o corpo, detecta obstáculos no caminho, desliza ao longo deles (andar encostado numa parede, subir uma rampa) e atualiza IsOnFloor(), IsOnWall() e IsOnCeiling(). O personagem se move exatamente como o código manda, com colisão, e nunca é empurrado por forças que o jogador não controla.
RigidBody2D é o oposto: o motor decide o movimento. O código aplica forças e impulsos (ApplyImpulse), e a física calcula quique, atrito, rotação e empilhamento. É ótimo para objetos que devem parecer físicos e péssimo para personagens, porque definir a posição de um corpo rígido diretamente briga com a simulação. Na "Biblioteca em Apuros", o livro que não foi apanhado vira um RigidBody2D ao tocar o chão: quica, gira e fica caído por um segundo antes de sumir. É um detalhe puramente visual, que dá vida à cena sem afetar as regras.
Area2D não bloqueia nada, só avisa quando algo entra ou sai (AreaEntered, BodyEntered e seus pares de saída). Os livros em queda são áreas movidas pelo código em linha reta, porque um livro que quicasse na cabeça da bibliotecária ou nas prateleiras tornaria o jogo imprevisível. O jogador precisa prever onde o livro vai cair. Física realista nem sempre é física divertida.
O que é uma colisão, matematicamente
Por baixo das áreas e corpos, detectar colisão é geometria. O caso mais simples, e o mais usado em jogos 2D, é o AABB (axis-aligned bounding box), o retângulo alinhado aos eixos que o protótipo usou com Rectangle.Intersects:
public readonly record struct Caixa(float X, float Y, float Largura, float Altura)
{
public bool Intersecta(Caixa o) =>
X < o.X + o.Largura && X + Largura > o.X &&
Y < o.Y + o.Altura && Y + Altura > o.Y;
}
Dois retângulos alinhados não se sobrepõem se um está inteiramente à esquerda, à direita, acima ou abaixo do outro. Negando essa condição, as quatro comparações acima dizem que eles se sobrepõem. São quatro comparações de ponto flutuante: barato o suficiente para testar milhares de pares por quadro.
Para círculos, a sobreposição acontece quando a distância entre os centros é menor que a soma dos raios. Compara-se o quadrado da distância com o quadrado da soma, para evitar a raiz quadrada:
static bool CirculosIntersectam(Vector2 c1, float r1, Vector2 c2, float r2) =>
(c1 - c2).LengthSquared() < (r1 + r2) * (r1 + r2);
Formas rotacionadas e polígonos convexos usam o Teorema do Eixo Separador (SAT): dois polígonos convexos não colidem se e somente se existe uma reta que os separa, e basta testar as projeções nos eixos perpendiculares a cada aresta. Se houver sobreposição em todos os eixos, eles colidem, e o eixo de menor sobreposição dá a direção e a profundidade para separá-los, que é o que o MoveAndSlide faz para o personagem não afundar na parede.
Fase ampla e fase estreita
Testar cada objeto contra todos os outros é O(n²): com 1.000 objetos, meio milhão de pares por passo. Os motores dividem o trabalho em duas fases:
- Fase ampla (broad phase): uma estrutura espacial, como uma grade com spatial hashing ou uma árvore de volumes envolventes, descarta rapidamente os pares que estão longe. Só sobram os candidatos próximos.
- Fase estreita (narrow phase): o teste geométrico exato (AABB, círculos, SAT) só nos candidatos.
O Godot faz as duas coisas internamente. Na "Biblioteca em Apuros", com poucas dezenas de objetos, nenhuma delas pesa. O motivo para conhecê-las é saber o que cobrar do motor, e o que fazer em jogos com milhares de entidades, em que uma fase ampla escrita sob medida ou um ECS de "Arquitetura de jogo e por que não alocar dentro do loop" fazem diferença.
Tunelamento
O exercício 2 de "Um game loop do zero com MonoGame" mostrou o problema: se um objeto se move, num passo, mais do que a espessura do que deveria atingir, o teste de sobreposição no fim do passo não encontra nada, e ele atravessa. Um livro raro a 400 pixels por segundo percorre quase 7 pixels por passo. Como o livro tem 16 pixels de altura e a faixa das mãos 8, ele só passa sem ser detectado se andar mais de 24 pixels num único passo, ou seja, acima de 1.440 pixels por segundo (a conta está no exercício 3). Com a dificuldade crescendo sem teto, essa velocidade acaba chegando, e um engasgo que alongue o passo a antecipa.
Há três defesas, da mais simples à mais cara:
- Formas mais grossas que o deslocamento máximo por passo. A área das mãos passa de 8 para 14 pixels, e a velocidade máxima dos livros ganha um teto. Resolve na maioria dos jogos 2D.
- Mais passos físicos por segundo. Em Configurações do Projeto → Física → Comum → Ticks por segundo, 120 em vez de 60 corta o deslocamento por passo pela metade, ao custo de dobrar o trabalho da simulação.
- Detecção contínua (CCD): em vez de testar só a posição final, o motor calcula se a forma varreu algo no caminho, com um teste de interseção entre o volume percorrido e os obstáculos (swept test). O
RigidBody2Dtem a propriedadeContinuousCd, e oCharacterBody2Djá testa o caminho noMoveAndSlide. Áreas movidas por posição não têm CCD, e para elas vale a defesa 1 ou um raycast do ponto anterior ao atual.
Camadas e máscaras
Nem tudo precisa colidir com tudo. Livros não devem colidir com outros livros, traças não devem ser bloqueadas pelo chão, e as mãos só interessam aos livros. O Godot organiza isso com camadas e máscaras, até 32 de cada:
- A camada diz em que grupos o objeto está.
- A máscara diz que grupos o objeto enxerga.
Primeiro, dar nomes às camadas em Configurações do Projeto → Nomes das Camadas → Física 2D:
| Camada | Nome |
|---|---|
| 1 | mundo (chão, prateleiras) |
| 2 | bibliotecaria (corpo) |
| 3 | maos |
| 4 | livros |
| 5 | tracas |
| 6 | limites (chão invisível que recolhe livros perdidos) |
Depois, configurar cada objeto:
| Objeto | Camada | Máscara | Por quê |
|---|---|---|---|
| Chão | mundo | — | obstáculo passivo |
| Bibliotecária (corpo) | bibliotecaria | mundo | pisa no chão, não é bloqueada por livros |
| Mãos | maos | livros | só detecta livros |
| Livro | livros | maos | só precisa saber das mãos; quem recolhe o livro perdido é o limite |
| Traça | tracas | bibliotecaria | detecta o corpo da bibliotecária |
| Limite inferior | limites | livros | recolhe livros perdidos |
O teste if (outra.Name != "Maos") return; no Livro do artigo anterior se torna desnecessário: a máscara garante que o livro só é avisado quando encosta nas mãos. Se a máscara do livro incluísse também limites, o mesmo AreaEntered dispararia no chão invisível, e um livro perdido contaria como apanhado. Configurar camadas e máscaras no editor elimina uma categoria inteira de if no código, e o motor descarta os pares irrelevantes já na fase ampla, sem nem chegar ao teste geométrico.
As traças
As traças completam o perigo do jogo. Elas voam em trajetória ondulada entre as prateleiras e tiram uma vida ao encostar na bibliotecária:
public partial class Traca : Area2D
{
[Export] public float VelocidadeHorizontal { get; set; } = 60f;
[Export] public float Amplitude { get; set; } = 18f;
[Export] public float Frequencia { get; set; } = 2.5f;
[Signal] public delegate void AcertouEventHandler();
private float alturaBase;
private float tempo;
public override void _Ready()
{
alturaBase = Position.Y;
BodyEntered += AoEncostar;
}
public override void _PhysicsProcess(double delta)
{
tempo += (float)delta;
Position = new Vector2(
Position.X + VelocidadeHorizontal * (float)delta,
alturaBase + MathF.Sin(tempo * Frequencia * MathF.Tau) * Amplitude);
if (Position.X > 520 || Position.X < -40) QueueFree();
}
private void AoEncostar(Node2D corpo)
{
if (corpo is Bibliotecaria b && b.PodeSerAtingida)
EmitSignal(SignalName.Acertou);
}
}
A trajetória em seno é determinística: dada a hora de nascimento, a posição da traça em qualquer momento é previsível, e o jogador aprende o padrão. Um inimigo que muda de direção aleatoriamente a cada quadro parece injusto. Um que segue um padrão legível parece um desafio.
Quadros de invencibilidade
Quando a traça acerta, a bibliotecária perde uma vida e fica invencível por 1,5 segundo, piscando. Sem isso, uma traça que passa lentamente pelo corpo dela dispararia BodyEntered várias vezes seguidas (ao sair e entrar de novo por causa da ondulação) e tiraria todas as vidas num único encontro:
// na Bibliotecaria
[Export] public float TempoDeInvencibilidade { get; set; } = 1.5f;
private float invencivel;
public bool PodeSerAtingida => invencivel <= 0;
public void ReceberGolpe(Vector2 origem)
{
invencivel = TempoDeInvencibilidade;
var afastamento = Mathf.Sign(GlobalPosition.X - origem.X);
Velocity = new Vector2(afastamento * 200f, -160f); // pequeno empurrão
}
public override void _Process(double delta)
{
if (invencivel > 0)
{
invencivel -= (float)delta;
sprite.Modulate = new Color(1, 1, 1, (int)(invencivel * 10) % 2 == 0 ? 0.3f : 1f);
}
else sprite.Modulate = Colors.White;
}
O piscar fica em _Process, porque é visual, e o contador de invencibilidade decresce ali também, porque não afeta a simulação de forma que exija determinismo quadro a quadro. Em um jogo competitivo, ele iria para _PhysicsProcess. O empurrão (knockback) afasta a bibliotecária da traça e deixa claro, pela física, que ela foi atingida.
Hitboxes generosas e mesquinhas
A última regra de sensação de jogo vale mais do que qualquer algoritmo: as formas de colisão não precisam coincidir com os desenhos, e em geral não devem.
- Coisas boas para o jogador (livros, as mãos) têm hitboxes maiores que o desenho. Um livro que passou "raspando" pela mão conta. O jogador sente que o jogo o recompensa pela intenção.
- Coisas ruins para o jogador (traças) têm hitboxes menores que o desenho. A asa da traça passou pelo cabelo da bibliotecária e não tirou vida. O jogador sente que escapou por pouco, em vez de sentir que foi roubado.
A regra pode ser verificada no editor: com Depurar → Formas de Colisão Visíveis ligado, o jogo mostra todas as formas de colisão por cima dos sprites, e fica evidente onde o jogo é justo e onde não é. Muitos dos jogos considerados "precisos" são, na verdade, sistematicamente generosos com o jogador.
A "Biblioteca em Apuros" agora tem um controle que responde como o jogador espera: ações configuráveis para teclado e controle, um pulo com buffer, tempo coiote e altura variável, aceleração e atrito ajustáveis, colisões organizadas em camadas e máscaras, livros que não atravessam as mãos em alta velocidade, traças com padrões legíveis e um momento de invencibilidade depois de cada golpe. O jogo já é jogável e justo. Ainda é, porém, um amontoado de nós que se comunicam como der, e com traças, livros, livros caídos e efeitos sendo criados e destruídos o tempo todo.
Fontes e leituras recomendadas
- Godot Engine — Input examples e InputEvent: https://docs.godotengine.org/en/stable/tutorials/inputs/input_examples.html
- Godot Engine — Physics introduction, Using CharacterBody2D e Collision layers and masks: https://docs.godotengine.org/en/stable/tutorials/physics/physics_introduction.html
- Steve Swink — Game Feel: A Game Designer's Guide to Virtual Sensation (Morgan Kaufmann, 2008).
- Christer Ericson — Real-Time Collision Detection (Morgan Kaufmann, 2004), capítulos sobre AABB, SAT e fase ampla.
- Maddy Thorson — Celeste and TowerFall Physics (2017), sobre o movimento em jogos de plataforma: https://maddythorson.medium.com/celeste-and-towerfall-physics-d24bd2ae0fc5
- Game Maker's Toolkit — Why Does Celeste Feel So Good to Play? (vídeo, 2019), sobre tempo coiote e buffer de pulo.
Exercícios
Exercício 1
Remova o buffer de pulo e peça a alguém que jogue por dois minutos tentando pular assim que pousa. Depois reative. Explique, em termos de quadros, por que o buffer de 0,12 s faz diferença, e por que um buffer de 1 segundo seria ruim.
Ver resposta
✓ Resposta: A 60 passos por segundo, 0,12 s são cerca de 7 passos. Um jogador que aperta o botão "no momento do pouso" costuma apertar alguns quadros antes do contato real, porque o tempo de reação e a percepção da animação não coincidem com o passo exato em que IsOnFloor() passa a ser verdadeiro. Sem buffer, qualquer aperto antes daquele passo é descartado, e o jogador sente que "apertou e não pulou". Com buffer, o pedido fica guardado por 7 passos e é executado no primeiro passo em chão. Com 1 segundo (60 passos), o jogador que aperta pular no meio de uma queda longa, desistindo do pulo, pularia de novo ao pousar, sem querer, quase um segundo depois do aperto. O buffer passa a executar intenções velhas, e o controle parece ter vontade própria. O valor certo é o menor que cobre o descompasso humano, tipicamente de 0,08 a 0,15 s.
Exercício 2
Configure camadas e máscaras para uma nova entidade, o marcador de página, um item que aparece de vez em quando, voa em linha reta, dá uma vida extra ao ser apanhado pelas mãos e não deve ser recolhido pelo limite inferior. Justifique cada escolha.
Ver resposta
✓ Resposta: Criar a camada 7, "itens". O marcador é uma Area2D na camada itens, com máscara maos: ele só precisa saber quando encosta nas mãos, e sem limites na máscara ele não é recolhido pelo chão invisível. Para ele sumir, o próprio script chama QueueFree ao sair da tela. As mãos precisam enxergar a nova camada, então a máscara delas passa a ser livros, itens. Outra opção é deixar as mãos como estão e só o marcador olhar para elas, porque em Godot basta que um dos lados tenha o outro na máscara para o sinal ser emitido do lado que observa. O marcador não entra na máscara das traças, que continuam só com bibliotecaria, e não entra na do limite, que continua só com livros. Resultado: nenhum if de tipo no código, e o motor nem calcula os pares irrelevantes.
Exercício 3
Um livro raro a 480 px/s e as mãos com 8 px de altura, a 60 passos por segundo. Calcule se há risco de tunelamento e proponha a menor mudança que o elimina sem mudar a velocidade.
Ver resposta
✓ Resposta: Deslocamento por passo: 480 / 60 = 8 px. O livro tem 16 px de altura e as mãos 8 px, e a sobreposição é detectada quando as caixas se cruzam no fim do passo. Para o livro passar sem ser detectado, ele precisaria ir de "inteiramente acima" das mãos para "inteiramente abaixo" num passo, o que exige deslocamento maior que 16 + 8 = 24 px. Com 8 px por passo, ele necessariamente termina algum passo sobreposto, e não há tunelamento nesse caso. O risco aparece com a dificuldade crescendo: a 1.500 px/s (25 px por passo), o livro pode saltar a região de 24 px entre um passo e outro. A menor mudança sem limitar a velocidade: aumentar a altura efetiva da área das mãos para garantir que "altura do livro + altura das mãos" seja sempre maior que o deslocamento máximo por passo. Com velocidade máxima prevista de 1.800 px/s (30 px por passo) e o livro com 16 px, as mãos precisam de mais de 14 px. Outra opção, sem mudar as formas, é um RayCast2D do ponto anterior ao atual do livro em cada passo.
Exercício 4
Por que os livros em queda são Area2D movidas por código e não RigidBody2D com gravidade? Descreva o que mudaria na jogabilidade se fossem corpos rígidos.
Ver resposta
✓ Resposta: Como Area2D movidas em linha reta com velocidade constante, a trajetória de cada livro é perfeitamente previsível: o jogador vê onde ele está e sabe onde vai cair, e a habilidade do jogo é ler a chuva e se posicionar. Com RigidBody2D, os livros acelerariam com a gravidade (difícil estimar o momento de chegada), colidiriam entre si (desviando uns aos outros), quicariam nas prateleiras e na cabeça da bibliotecária (trajetórias caóticas) e girariam. A jogabilidade passaria de "ler e reagir" para "torcer": o mesmo movimento do jogador daria resultados diferentes conforme pequenas variações de colisão. Além disso, a física de corpos rígidos no Godot não é garantidamente determinística entre máquinas, o que atrapalharia um "desafio do dia" com semente compartilhada. O RigidBody2D entra só depois que a regra já foi decidida, no livro perdido que quica no chão, onde o caos é bem-vindo porque é só visual.
Exercício 5
A traça entra no corpo da bibliotecária, sai por causa da ondulação e entra de novo em 0,3 s. Sem invencibilidade, quantas vidas seriam perdidas? Explique por que a invencibilidade fica na bibliotecária e não em cada traça.
Ver resposta
✓ Resposta: Cada entrada dispara BodyEntered, e cada disparo tiraria uma vida: duas entradas em 0,3 s tiram duas vidas, e com uma traça lenta e uma ondulação curta poderiam ser três, ou seja, a partida inteira num encontro que, para o jogador, foi "um toque". A invencibilidade fica na bibliotecária porque o que precisa ser limitado é o dano recebido por ela, e não o dano causado por uma traça específica. Se cada traça guardasse um "já acertei", duas traças diferentes encostando juntas, ou uma logo depois da outra, ainda tirariam duas vidas no mesmo instante, e o jogador não teria tempo de reagir. Com o contador na bibliotecária, qualquer fonte de dano é ignorada durante a janela, e o jogador sempre tem 1,5 s para sair do perigo. O PodeSerAtingida exposto por ela e consultado pela traça mantém a regra num lugar só, e um novo tipo de inimigo a respeita sem código extra.