RayCast2D no Godot: Detecção de Visão e Colisão

Aprenda raycast no Godot 4 com RayCast2D: linha de visão de inimigo, ground check e consulta por código com intersect_ray. Tutorial prático em GDScript.
RayCast2D no Godot: Detecção de Visão e Colisão
Raycast no Godot é uma linha invisível que responde uma pergunta: "tem algo entre o ponto A e o ponto B?". Parece pouco, mas com essa pergunta você resolve três problemas que todo jogo 2D tem: o inimigo consegue ver o player? Tem chão na frente ou ele vai cair do penhasco? Onde esse tiro acerta?
A diferença pra colisão normal é que raycast não é física, é consulta. Nada se move, nada quica, nada empurra. Você pergunta, a engine responde, e você decide o que fazer com a resposta. Por isso é barato e por isso é a ferramenta certa pra percepção: visão, sensores, mira.
Nesse tutorial eu monto os três casos clássicos com RayCast2D: linha de visão de inimigo com cone, patrulha que não cai da plataforma, e raycast por código quando você não quer um node fixo. Tudo em GDScript, Godot 4.x, código que roda como está.
Como funciona o raycast no Godot
O Godot te dá dois caminhos pra atirar um ray:
- Node RayCast2D: você adiciona na cena, posiciona, e ele atualiza sozinho a cada frame de física. Ideal pra sensores fixos: visão de inimigo, checagem de chão, detecção de parede.
- Consulta direta no espaço de física (
intersect_ray): você dispara o ray por código, de qualquer ponto pra qualquer ponto, na hora que quiser. Ideal pra coisas dinâmicas: tiro, mira do mouse, checagens pontuais.
Regra prática: se o ray existe o tempo todo e aponta sempre pro mesmo lugar relativo, use o node. Se o ray nasce e morre num evento, use a consulta por código.
Setup do node RayCast2D
Adicione um RayCast2D como filho do seu personagem ou inimigo. Três propriedades no Inspector importam de verdade:
- Enabled: precisa estar ligado, senão o ray não atualiza. É a causa número um de "meu raycast não funciona".
- Target Position: pra onde a linha aponta, em coordenadas locais do node.
(100, 0)significa 100 pixels pra direita do raycast, não do mundo. - Collision Mask: quais camadas o ray enxerga. Se a camada do alvo não está marcada aqui, o ray atravessa ele como se não existisse.
Com o node configurado, a consulta é direta:
@onready var raycast = $RayCast2D
func _physics_process(delta):
if raycast.is_colliding():
var alvo = raycast.get_collider()
var ponto = raycast.get_collision_point() # em coordenadas globais
var normal = raycast.get_collision_normal()
print("acertou ", alvo.name, " em ", ponto)
Um detalhe que economiza horas: o node atualiza uma vez por frame de física. Se você mudar target_position por código e ler is_colliding() no mesmo frame, vai receber o resultado do frame anterior. A solução é forçar a atualização:
raycast.target_position = Vector2(200, 0)
raycast.force_raycast_update() # recalcula agora, sem esperar o próximo frame
if raycast.is_colliding():
pass # resultado já é o novo
Linha de visão de inimigo
O caso de uso mais famoso do raycast. A lógica de "o inimigo vê o player" tem três testes, do mais barato pro mais caro:
- Distância: o player está dentro do alcance?
- Cone de visão: o player está na frente do inimigo, não atrás?
- Linha de visão: tem parede no meio do caminho?
Os dois primeiros são matemática pura e filtram a maioria dos frames sem gastar raycast. Só o terceiro usa o ray.
extends CharacterBody2D
@export var player: Node2D
@export var alcance := 250.0
@export var angulo_visao := 60.0 # graus pra cada lado da direção que ele olha
@onready var visao: RayCast2D = $Visao
var facing := 1 # 1 olhando pra direita, -1 pra esquerda
func ve_o_player() -> bool:
var para_player = player.global_position - global_position
# Teste 1: longe demais? Nem continua.
if para_player.length() > alcance:
return false
# Teste 2: fora do cone? O dot product entre a direção que o inimigo
# olha e a direção do player diz o ângulo entre os dois.
var frente = Vector2(facing, 0)
if frente.dot(para_player.normalized()) < cos(deg_to_rad(angulo_visao)):
return false
# Teste 3: aponta o ray pro player e vê se chega lá sem obstáculo.
visao.target_position = visao.to_local(player.global_position)
visao.force_raycast_update()
return visao.is_colliding() and visao.get_collider() == player
O pulo do gato está na collision mask do raycast: ela precisa incluir a camada do player E a camada das paredes. Se a mask só tiver o player, o ray atravessa parede e o inimigo ganha visão de raio-x. Com as duas camadas na mask, o ray para no primeiro obstáculo que encontrar: se esse obstáculo é o player, ele está visível; se é uma parede, está escondido. É por isso que o return confere get_collider() == player em vez de só is_colliding().
Outro detalhe: visao.to_local() converte a posição global do player pro espaço local do raycast, que é o que target_position espera. Usar a posição global direto ali é o segundo erro mais comum com esse setup, e o sintoma é o ray apontando pra um lugar absurdo do mapa.
Pra transformar isso num inimigo de stealth funcional, chame ve_o_player() no _physics_process e troque de estado quando retornar true. Memória de "última posição vista" é só guardar player.global_position no momento da detecção e mandar o inimigo investigar ali quando perder a visão.
Ground check: patrulha que não cai da plataforma
O CharacterBody2D já responde "estou no chão?" com is_on_floor(). O que ele não responde é "tem chão na minha frente?", e essa é a pergunta que faz um inimigo de patrulha dar meia-volta na beirada em vez de despencar.
A receita usa dois raycasts como filhos do inimigo:
- ChaoAFrente: posicionado um pouco à frente do corpo (uns 20 pixels), com
target_positionapontando pra baixo, tipo(0, 30). Enquanto ele colide, tem chão à frente. Quando para de colidir, é beirada. - ParedeAFrente: na altura do corpo, com
target_positionapontando pra frente, tipo(20, 0). Quando colide, tem parede.
extends CharacterBody2D
const SPEED = 60.0
var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")
var direction := 1
@onready var chao_a_frente: RayCast2D = $ChaoAFrente
@onready var parede_a_frente: RayCast2D = $ParedeAFrente
func _physics_process(delta):
if not is_on_floor():
velocity.y += gravity * delta
elif not chao_a_frente.is_colliding() or parede_a_frente.is_colliding():
virar()
velocity.x = direction * SPEED
move_and_slide()
func virar():
direction *= -1
# Espelha os sensores pro novo lado.
chao_a_frente.position.x = abs(chao_a_frente.position.x) * direction
parede_a_frente.target_position.x = abs(parede_a_frente.target_position.x) * direction
Repare que a checagem de beirada só roda quando is_on_floor() é verdadeiro. Sem esse elif, o inimigo viraria no ar durante uma queda, porque o sensor de chão obviamente não colide com nada enquanto ele cai.
E note a função virar(): em vez de inverter a escala do corpo (que dá problema com CharacterBody2D), ela espelha a posição de um sensor e o alvo do outro. O sprite você vira com flip_h, separado da física.
Esse mesmo padrão de "ray pra baixo, à frente" serve pra mais coisas: detectar degrau antes de subir, alinhar o personagem à inclinação de uma rampa usando get_collision_normal(), ou implementar ledge grab checando o ponto exato onde a parede termina.
Raycast por código com intersect_ray
Quando o ray é evento, não sensor, você fala direto com o espaço de física. Tiro hitscan é o exemplo clássico: o jogador clica, você atira um ray da arma até onde o mouse aponta, e o que o ray tocar primeiro leva o dano.
func atirar():
var space_state = get_world_2d().direct_space_state
var origem = global_position
var destino = get_global_mouse_position()
var query = PhysicsRayQueryParameters2D.create(origem, destino)
query.exclude = [get_rid()] # ignora o próprio corpo que atira
query.collision_mask = 0b110 # só camadas 2 e 3 (inimigos e paredes)
var resultado = space_state.intersect_ray(query)
if resultado:
var alvo = resultado.collider
if alvo.has_method("tomar_dano"):
alvo.tomar_dano(10)
# resultado.position e resultado.normal servem pra spawnar
# o efeito de impacto no ponto certo, virado pro lado certo.
Três coisas pra não tropeçar:
O retorno é um Dictionary. Quando o ray não acerta nada, vem vazio, então if resultado: já resolve o teste. Quando acerta, as chaves principais são collider, position e normal.
O exclude evita o tiro no próprio pé. Sem ele, o ray nasce dentro do collider de quem atira e colide consigo mesmo no primeiro pixel. O get_rid() funciona em qualquer corpo físico. No node RayCast2D o equivalente é a propriedade exclude_parent, que já vem ligada por padrão.
Chame dentro do _physics_process ou de um handler de input. O direct_space_state só pode ser consultado quando o espaço de física não está travado calculando o passo. Na prática, _physics_process e callbacks de input funcionam; threads e momentos exóticos do frame, não.
E quando a linha é fina demais: ShapeCast2D
O raycast é uma linha de espessura zero. Se você precisa perguntar "esse corpo com volume passa por aqui?", a linha pode passar por uma fresta que o corpo não passa. Pra isso existe o ShapeCast2D: mesma ideia, mesma API (is_colliding(), get_collider()), mas varrendo uma forma inteira pelo caminho em vez de uma linha. Use pra antecipar se um dash termina dentro da parede ou se um inimigo largo cabe na passagem.
Debug: enxergue seus rays
Raycast invisível é raycast que você debuga no escuro. Duas ferramentas resolvem:
Visible Collision Shapes. No editor, Debug > Visible Collision Shapes. Com isso ligado, todo node RayCast2D aparece como uma seta durante o jogo, e a maioria dos bugs se revela na hora: ray apontando pro lado errado, curto demais, na posição errada.
Desenhar o ray por código. Pra rays criados com intersect_ray, que não têm node, desenhe a linha você mesmo num node com _draw():
var debug_destino := Vector2.ZERO
func _draw():
if debug_destino != Vector2.ZERO:
draw_line(Vector2.ZERO, to_local(debug_destino), Color.RED, 2.0)
func _process(delta):
queue_redraw()
Basta guardar o destino do último tiro em debug_destino e a linha aparece. Tire antes de shippar, claro.
Fechando
Raycast é daquelas ferramentas pequenas que destravam sistemas grandes. A mecânica é sempre a mesma, uma pergunta de linha reta pro mundo físico, e em cima dela você constrói visão de inimigo, patrulha esperta, tiro hitscan, sensor de rampa.
Os erros que travam iniciante também são sempre os mesmos: mask sem a camada certa, target_position global em vez de local, leitura no mesmo frame sem force_raycast_update, e ray colidindo com o próprio dono. Conhecendo esses quatro, o resto é montar.
Sugestão de exercício: pegue um inimigo parado e implemente o cone de visão deste artigo. Depois ligue ele à patrulha com ground check. Em menos de uma hora você tem um inimigo de stealth completo, e o raycast deixa de ser conceito pra virar ferramenta que você saca sem pensar.


