Voltar para o Blog
Quest Log

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

Personagem 3D com mira travada em um inimigo, mostrando sistema de lock-on na Godot 4

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:

  1. Mais próximo do jogador: simples, funciona bem em 2D e em combate corpo a corpo.
  2. 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()
Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

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_valid cobre o caso do inimigo que deu queue_free ao morrer. Sem essa checagem, o primeiro inimigo derrotado derruba seu jogo com erro de referência inválida.
  • A margem alcance * 1.5 cria 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, Vector3 e Area3D viram Node2D, Vector2 e Area2D. A busca por grupo e distance_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_custom por distância e pegue o primeiro.
  • Para mirar, look_at existe em Node2D e 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 Camera2D para um ponto médio, jogador.global_position.lerp(alvo.global_position, 0.3), e os dois ficam no quadro.
  • O indicador vira um Sprite2D sobre o inimigo, sem billboard, e a troca de alvo por ângulo usa atan2(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.