Lock-on na Godot 4: Como Travar Alvo Estilo Zelda e Souls-Like (GDScript)

Lock-on na Godot 4: aprenda a travar alvo estilo Zelda e souls-like com GDScript tipado. Detecção por grupos, troca de alvo e câmera que segue o combate.
Lock-on na Godot 4 é aquele sistema de travar alvo que você conhece de Zelda (o clássico Z-targeting) e de todo souls-like: aperta um botão, a câmera trava em um inimigo, o personagem passa a encará-lo e você circula em volta dele com segurança. Neste tutorial você vai montar o sistema completo em 3D, com detecção de inimigos, escolha do melhor alvo, troca de alvo por input, câmera suave, indicador visual e strafe, e no final adaptar tudo para 2D top-down.
O que é lock-on e por que jogos de ação usam
Sem lock-on, combate em terceira pessoa vira uma briga contra a câmera. O jogador precisa mirar manualmente, o inimigo sai do quadro e metade dos ataques erra por causa de ângulo. O lock-on resolve três problemas de uma vez:
- Leitura de combate: com o inimigo sempre no centro da tela, o jogador enxerga a animação de ataque dele e consegue reagir. É por isso que sistemas de defesa como o do nosso tutorial de parry e bloqueio com escudo na Godot ficam muito melhores com lock-on: reagir a um golpe exige ver o golpe.
- Câmera previsível: a câmera para de depender do analógico direito e passa a enquadrar jogador e alvo. Menos carga mental, mais foco na luta.
- Ataques que acertam: com o personagem sempre virado para o alvo, o ataque vai na direção certa sem o jogador precisar ajustar.
Se você está construindo um jogo de ação completo, este sistema conversa direto com o nosso guia de como fazer um souls-like na Godot. Aqui o foco é só o lock-on, em profundidade.
A arquitetura que vamos usar: um nó LockOnSystem filho do jogador, inimigos no grupo "enemies", e sinais tipados para avisar o resto do jogo quando um alvo é travado ou solto.
Detectando inimigos próximos
Você tem duas opções clássicas: um Area3D esférico que mantém uma lista via body_entered e body_exited, ou grupos com filtro de distância. Para lock-on, o grupo é mais simples e evita estado duplicado: a lista de candidatos é calculada na hora do aperto do botão, não mantida o tempo todo.
Primeiro, todo inimigo entra no grupo. Na cena do inimigo, marque o grupo enemies no painel Node > Groups, ou via código no _ready:
# No script do inimigo
func _ready() -> void:
add_to_group("enemies")
Agora o esqueleto do sistema, com a busca por alcance:
class_name LockOnSystem
extends Node3D
signal alvo_travado(alvo: Node3D)
signal alvo_solto()
@export var alcance: float = 12.0
@export var camera: Camera3D
@export var jogador: CharacterBody3D
var alvo_atual: Node3D = null
func buscar_candidatos() -> Array[Node3D]:
var candidatos: Array[Node3D] = []
for node: Node in get_tree().get_nodes_in_group("enemies"):
var inimigo: Node3D = node as Node3D
if inimigo == null:
continue
var distancia: float = jogador.global_position.distance_to(inimigo.global_position)
if distancia <= alcance:
candidatos.append(inimigo)
return candidatos
Repare nos detalhes de GDScript 4 tipado: Array[Node3D] em vez de Array genérico, o cast com as Node3D protegendo contra alguém colocar um nó errado no grupo, e os sinais declarados com o tipo do argumento.
Se preferir Area3D, a lista vem dos sinais da área, mas a lógica de escolha de alvo a seguir é idêntica.
Travar alvo na Godot: escolhendo o melhor candidato
Ter a lista de inimigos no alcance não basta. Se há três inimigos perto, qual travar? Os dois critérios usados pela indústria:
- Mais próximo do jogador: simples, funciona bem em 2D e em combate corpo a corpo.
- Mais próximo do centro da tela: o que Zelda e os souls fazem. O jogador aponta a câmera para o inimigo que quer e aperta o botão. A intenção dele está na câmera.
O segundo critério usa produto escalar (dot product). A direção "para frente" da câmera na Godot é -basis.z. O produto escalar entre essa direção e a direção da câmera até o inimigo dá um número entre -1 e 1: quanto mais perto de 1, mais centralizado o inimigo está na tela.
func escolher_alvo() -> Node3D:
var melhor: Node3D = null
var melhor_pontuacao: float = 0.5
var frente_camera: Vector3 = -camera.global_transform.basis.z
for inimigo: Node3D in buscar_candidatos():
var direcao: Vector3 = camera.global_position.direction_to(inimigo.global_position)
var pontuacao: float = frente_camera.dot(direcao)
if pontuacao > melhor_pontuacao:
melhor_pontuacao = pontuacao
melhor = inimigo
return melhor
O valor inicial 0.5 da pontuação funciona como cone de visão: só inimigos a menos de uns 60 graus do centro da tela podem ser travados. Inimigo atrás do jogador não entra, o que evita travas surpresa.
O input que liga tudo (configure as ações no Input Map: lock_on, trocar_alvo_dir, trocar_alvo_esq):
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("lock_on"):
if alvo_atual != null:
soltar_alvo()
else:
var alvo: Node3D = escolher_alvo()
if alvo != null:
travar(alvo)
func travar(alvo: Node3D) -> void:
alvo_atual = alvo
alvo_travado.emit(alvo)
func soltar_alvo() -> void:
alvo_atual = null
alvo_solto.emit()
Alternando de alvo com input
Em luta contra grupo, o jogador precisa trocar de alvo sem soltar o lock. O padrão souls-like: flick no analógico direito (ou tecla) troca para o próximo inimigo à direita ou à esquerda. A forma robusta de decidir "quem é o próximo à direita" é ordenar os candidatos pelo ângulo em torno do jogador, visto de cima:
func alternar_alvo(sentido: int) -> void:
if alvo_atual == null:
return
var candidatos: Array[Node3D] = buscar_candidatos()
if candidatos.size() < 2:
return
var angulo_de: Callable = func(inimigo: Node3D) -> float:
var delta_pos: Vector3 = inimigo.global_position - jogador.global_position
return atan2(delta_pos.x, delta_pos.z)
candidatos.sort_custom(
func(a: Node3D, b: Node3D) -> bool:
return angulo_de.call(a) < angulo_de.call(b)
)
var indice: int = candidatos.find(alvo_atual)
if indice == -1:
travar(candidatos[0])
return
var proximo: int = wrapi(indice + sentido, 0, candidatos.size())
travar(candidatos[proximo])
O atan2(x, z) projeta a posição no plano horizontal e devolve o ângulo. Ordenando por ele, sentido = 1 anda para um lado do círculo e sentido = -1 para o outro, e wrapi fecha o círculo (do último volta para o primeiro). Ligue nos inputs dentro do mesmo _unhandled_input:
if event.is_action_pressed("trocar_alvo_dir"):
alternar_alvo(1)
elif event.is_action_pressed("trocar_alvo_esq"):
alternar_alvo(-1)
Fazendo personagem e câmera olharem para o alvo
Com alvo travado, duas coisas precisam girar: o corpo do jogador e a câmera. O corpo pode usar look_at direto, que na Godot 4 aponta o -Z do nó para o ponto dado. Só tome cuidado de mirar na altura do próprio jogador para o personagem não inclinar para cima ou para baixo:
# No script do jogador, dentro de _physics_process
func encarar_alvo(alvo: Node3D) -> void:
var ponto: Vector3 = alvo.global_position
ponto.y = global_position.y
look_at(ponto, Vector3.UP)
A câmera é mais delicada. look_at seco todo frame funciona, mas qualquer movimento do inimigo vira tranco na tela. O truque profissional tem duas partes: mirar em um ponto entre o jogador e o alvo (para os dois ficarem no quadro) e interpolar a rotação em vez de aplicá-la de uma vez. Assumindo o rig clássico de SpringArm3D com a câmera na ponta, gire o pivô do braço:
# No script do pivo da camera (Node3D pai do SpringArm3D)
@export var lock_on: LockOnSystem
@export var velocidade_giro: float = 8.0
func _physics_process(delta: float) -> void:
var alvo: Node3D = lock_on.alvo_atual
if alvo == null:
return
var foco: Vector3 = jogador.global_position.lerp(alvo.global_position, 0.5)
var destino: Transform3D = global_transform.looking_at(foco, Vector3.UP)
var peso: float = 1.0 - exp(-velocidade_giro * delta)
global_transform.basis = global_transform.basis.slerp(destino.basis, peso)
O slerp entre as bases faz a rotação seguir o arco curto de forma suave, e o peso 1.0 - exp(-velocidade * delta) deixa a suavização independente de framerate. Se o seu rig de câmera ainda não existe, monte primeiro seguindo o tutorial de câmera em terceira pessoa na Godot e depois plugue este bloco por cima: o lock-on simplesmente assume o controle da rotação enquanto há alvo.
Soltando o lock na hora certa
Um lock-on que persegue um inimigo morto ou a 40 metros de distância quebra o jogo. Três condições de saída, checadas todo frame no LockOnSystem:
func _physics_process(_delta: float) -> void:
if alvo_atual == null:
return
if not is_instance_valid(alvo_atual):
soltar_alvo()
return
var distancia: float = jogador.global_position.distance_to(alvo_atual.global_position)
if distancia > alcance * 1.5:
soltar_alvo()
Detalhes que importam:
is_instance_validcobre o caso do inimigo que deuqueue_freeao morrer. Sem essa checagem, o primeiro inimigo derrotado derruba seu jogo com erro de referência inválida.- A margem
alcance * 1.5cria histerese: o inimigo pode se afastar um pouco além do alcance de trava sem soltar na hora. Sem margem, um alvo dançando na borda do alcance liga e desliga o lock sem parar. - Se o inimigo tem sinal de morte (
morreu), conecte-o para soltar imediatamente e, se quiser o toque de qualidade dos souls, travar automaticamente no inimigo vivo mais próximo.
O caso "jogador soltou o botão" já está coberto no _unhandled_input: o mesmo botão trava e destrava.
Indicador visual do alvo travado
O jogador precisa ver em quem está travado. A forma mais direta em 3D é um Sprite3D com uma textura de retícula, em modo billboard para sempre encarar a câmera:
@onready var reticula: Sprite3D = $Reticula
func _ready() -> void:
reticula.billboard = BaseMaterial3D.BILLBOARD_ENABLED
reticula.visible = false
alvo_travado.connect(_ao_travar)
alvo_solto.connect(_ao_soltar)
func _ao_travar(alvo: Node3D) -> void:
reticula.visible = true
func _ao_soltar() -> void:
reticula.visible = false
func _process(_delta: float) -> void:
if alvo_atual != null and is_instance_valid(alvo_atual):
reticula.global_position = alvo_atual.global_position + Vector3.UP * 1.6
O offset Vector3.UP * 1.6 posiciona a retícula na altura do peito de um inimigo humanoide; exponha como @export se seus inimigos variam de tamanho. Alternativa em UI pura: um Control no HUD posicionado com camera.unproject_position(alvo.global_position), que converte a posição 3D para coordenadas de tela.
Strafe: circular o alvo mantendo o facing
A cereja do bolo. Com lock ativo, o personagem não gira na direção do movimento: ele mantém o corpo virado para o alvo e o input lateral vira passada lateral (strafe). No seu CharacterBody3D:
func _physics_process(delta: float) -> void:
var input: Vector2 = Input.get_vector("esquerda", "direita", "frente", "tras")
var alvo: Node3D = lock_on.alvo_atual
if alvo != null and is_instance_valid(alvo):
encarar_alvo(alvo)
var frente: Vector3 = -global_transform.basis.z
var lado: Vector3 = global_transform.basis.x
var direcao: Vector3 = (lado * input.x - frente * input.y).normalized()
velocity.x = direcao.x * velocidade
velocity.z = direcao.z * velocidade
else:
mover_livre(input, delta)
move_and_slide()
Como encarar_alvo já virou o corpo para o inimigo, a base do personagem aponta para ele: basis.x é o lado e -basis.z é a frente. Empurrar o analógico para o lado faz o personagem orbitar o alvo naturalmente, que é exatamente o movimento de esquiva circular dos jogos de ação. Nas animações, isso pede um blend space 2D com clipes de andar para frente, para trás e para os lados, alimentado pelo input.
Adaptando para 2D top-down
A boa notícia: a lógica inteira sobrevive, só encolhe uma dimensão. As trocas:
Node3D,Vector3eArea3DviramNode2D,Vector2eArea2D. A busca por grupo edistance_toé idêntica.- A pontuação por centro da tela raramente é necessária em top-down, porque a câmera já mostra tudo. Escolher o inimigo mais próximo resolve:
sort_custompor distância e pegue o primeiro. - Para mirar,
look_atexiste emNode2De aponta o eixo X positivo para o ponto. Para giro suave, use o ângulo:
# No script da mira 2D (Node2D filho do jogador)
@export var velocidade_giro: float = 10.0
func _physics_process(delta: float) -> void:
var alvo: Node2D = lock_on.alvo_atual
if alvo == null or not is_instance_valid(alvo):
return
var angulo_alvo: float = global_position.angle_to_point(alvo.global_position)
rotation = rotate_toward(rotation, angulo_alvo, velocidade_giro * delta)
- A câmera 2D não rotaciona: em vez disso, mova o
Camera2Dpara um ponto médio,jogador.global_position.lerp(alvo.global_position, 0.3), e os dois ficam no quadro. - O indicador vira um
Sprite2Dsobre o inimigo, sem billboard, e a troca de alvo por ângulo usaatan2(delta_pos.y, delta_pos.x)no lugar da versão 3D.
Próximos passos
Você saiu daqui com o ciclo completo: detectar por grupo e distância, escolher por produto escalar, trocar por ângulo, girar corpo com look_at, suavizar câmera com slerp, soltar com validação e histerese, sinalizar com retícula e mover com strafe. Teste com dois ou três inimigos de comportamentos diferentes e ajuste alcance, o cone de 0.5 e velocidade_giro até o combate parecer justo.
Um aviso de design: lock-on muda o balanceamento do jogo inteiro. Inimigos que eram difíceis de acertar ficam triviais, então compense com padrões de ataque mais agressivos ou inimigos em grupo.
Perguntas frequentes
O que é lock-on em jogos de ação?
Lock-on é o sistema que trava a mira e a câmera em um inimigo específico. O personagem passa a encarar esse alvo o tempo todo, os ataques vão na direção dele e a câmera mantém jogador e inimigo no quadro. Zelda popularizou como Z-targeting e a série Souls consolidou o padrão.
Como detectar inimigos próximos na Godot 4?
As duas formas mais comuns são um Area3D com CollisionShape esférico que emite body_entered e body_exited, ou colocar todos os inimigos no grupo enemies e filtrar por global_position.distance_to a cada busca. Para lock-on, o grupo com filtro de distância costuma bastar e é mais simples de manter.
Como escolher o melhor alvo para travar?
Combine dois critérios: distância até o jogador e alinhamento com a câmera. O alinhamento vem do produto escalar entre a direção para frente da câmera e a direção até o inimigo. Quanto mais perto de 1.0, mais centralizado o inimigo está na tela, que é o que o jogador espera travar.
Como fazer a câmera olhar para o alvo travado sem ficar brusca?
Não use look_at direto todo frame, porque o corte fica seco. Calcule o transform desejado com looking_at para um ponto entre jogador e alvo e interpole com interpolate_with usando um fator baseado em delta, como 1.0 - exp(-velocidade * delta). Isso suaviza e mantém os dois no quadro.
Quando o sistema deve soltar o lock-on automaticamente?
Em três situações: o alvo morreu ou foi removido da cena (cheque com is_instance_valid), o alvo saiu do alcance máximo com uma margem de tolerância, ou o jogador apertou o botão de soltar. Sempre limpe a referência, esconda o indicador visual e emita um sinal para o resto do jogo reagir.
Lock-on funciona em jogos 2D top-down?
Funciona muito bem. A lógica de detecção por grupo e distância é idêntica, só troca Node3D por Node2D. Para mirar, use look_at no nó da mira ou get_angle_to com rotate_toward para girar suave. A câmera em 2D geralmente só centraliza um ponto entre jogador e alvo em vez de rotacionar.


