Voltar para o Blog
Quest Log

Fog of War na Godot 4: Névoa de Guerra 2D Passo a Passo

Mapa 2D em tiles com névoa de guerra escura cobrindo o cenário e uma área circular revelada ao redor do personagem

Fog of war na Godot 4: tutorial 2D com TileMapLayer, revelação por raio, shroud estilo RTS e código GDScript 4 tipado pronto para usar no seu jogo.

Fog of war, ou névoa de guerra, é aquela mecânica que esconde o mapa até o jogador explorar: presente em praticamente todo RTS, roguelike e dungeon crawler que você já jogou. Neste tutorial você vai implementar fog of war 2D na Godot 4 do zero, usando um TileMapLayer de névoa por cima do mapa, revelação por raio ao redor do jogador, shroud semitransparente estilo Age of Empires e uma atualização otimizada que só recalcula quando a unidade muda de célula. Todo o código é GDScript 4 tipado, testável direto no editor, sem API inventada.

O Que É Fog of War e os Dois Tipos Clássicos

Antes de abrir a Godot, vale separar os dois comportamentos que costumam ser chamados de "fog of war", porque eles pedem estruturas diferentes:

1. Névoa de terreno inexplorado (a névoa "verdadeira"). Tudo que o jogador nunca viu fica coberto por preto absoluto. Quando uma célula é revelada, ela nunca mais volta a ficar totalmente escura. É o comportamento padrão de dungeon crawlers e roguelikes: o mapa vai sendo desenhado conforme você anda.

2. Shroud (sombra de visão). Áreas que o jogador já explorou, mas que estão fora do campo de visão atual das unidades, ficam cobertas por uma sombra semitransparente. Você enxerga o terreno, mas não vê o que se move ali. É o comportamento clássico de RTS: você sabe onde fica a base inimiga, mas não sabe o que ela está produzindo agora.

Um RTS completo usa os dois ao mesmo tempo, em três estados por célula: nunca vista (preto), já vista mas fora de visão (sombra), visível agora (limpa). Se você está construindo um jogo do gênero, vale ler também o guia de como fazer um jogo de estratégia RTS, porque fog of war é só uma das peças desse quebra-cabeça.

Vamos implementar os dois, começando pelo mais simples.

Montando o TileMapLayer de Névoa

A abordagem por tiles é a mais direta: um TileMapLayer extra, desenhado por cima do mapa do jogo, preenchido com tiles pretos. Revelar uma célula é literalmente apagar o tile daquela posição.

Estrutura de cena:

Nivel (Node2D)
├── Mapa (TileMapLayer)          # seu cenário normal
├── Jogador (CharacterBody2D)
├── FogSombra (TileMapLayer)     # shroud, vem depois
└── FogOpaca (TileMapLayer)      # névoa preta, por cima de tudo

O FogOpaca precisa de um TileSet próprio com um único tile: um quadrado preto do mesmo tamanho dos tiles do seu mapa (16x16, 32x32, o que você usa). Crie uma textura preta, adicione como atlas no TileSet e pronto. Garanta que os nós de névoa fiquem abaixo dos outros na árvore (nós mais abaixo desenham por cima) ou ajuste o z_index deles para um valor alto.

Se TileMapLayer, atlas e coordenadas de célula ainda são território novo para você, o guia completo de TileMap na Godot 4 cobre essa base antes de você continuar aqui.

Com a cena montada, o script que preenche a névoa no início do jogo:

extends Node2D

@export var fog_opaca: TileMapLayer
@export var fog_sombra: TileMapLayer
@export var jogador: Node2D
@export var raio_visao: int = 4
@export var largura_mapa: int = 40
@export var altura_mapa: int = 24

const FONTE_ID: int = 0
const TILE_NEVOA: Vector2i = Vector2i(0, 0)

var celulas_visiveis: Array[Vector2i] = []
var ultima_celula: Vector2i = Vector2i(-9999, -9999)


func _ready() -> void:
    fog_sombra.modulate = Color(0.0, 0.0, 0.0, 0.55)
    preencher_nevoa()


func preencher_nevoa() -> void:
    for x: int in range(largura_mapa):
        for y: int in range(altura_mapa):
            fog_opaca.set_cell(Vector2i(x, y), FONTE_ID, TILE_NEVOA)

FONTE_ID é o id da fonte do atlas no TileSet (0 se você só tem uma) e TILE_NEVOA é a coordenada do tile preto dentro do atlas. O set_cell do TileMapLayer recebe exatamente isso: célula, id da fonte e coordenada no atlas.

Repare no modulate do fog_sombra: em vez de criar um segundo tile cinza, usamos o mesmo tile preto e deixamos a camada inteira com 55% de opacidade. Um único TileSet serve para as duas camadas.

Se o seu mapa não é retangular, dá para preencher a névoa espelhando as células reais do cenário:

func preencher_nevoa_pelo_mapa(mapa: TileMapLayer) -> void:
    var celulas_usadas: Array[Vector2i] = mapa.get_used_cells()
    for celula: Vector2i in celulas_usadas:
        fog_opaca.set_cell(celula, FONTE_ID, TILE_NEVOA)

get_used_cells devolve um Array[Vector2i] com todas as células pintadas da camada, o que garante que a névoa cubra exatamente o que existe. Essa variação combina bem com mapas gerados em runtime, como os do tutorial de dungeon procedural na Godot: gere a masmorra, depois cubra as mesmas células com névoa.

Revelação Por Raio ao Redor do Jogador

Agora a parte central: descobrir quais células estão dentro do alcance de visão e apagar a névoa delas. Trabalhamos em distância de células, não de pixels, porque é mais barato e mais previsível.

func celulas_no_raio(centro: Vector2i, raio: int) -> Array[Vector2i]:
    var resultado: Array[Vector2i] = []
    for x: int in range(centro.x - raio, centro.x + raio + 1):
        for y: int in range(centro.y - raio, centro.y + raio + 1):
            var celula: Vector2i = Vector2i(x, y)
            var delta: Vector2i = celula - centro
            if delta.x * delta.x + delta.y * delta.y <= raio * raio:
                resultado.append(celula)
    return resultado

Dois detalhes de quem já apanhou disso:

  • A comparação usa distância ao quadrado. delta.x * delta.x + delta.y * delta.y <= raio * raio evita raiz quadrada e trabalha só com inteiros. Para raio 4, isso testa um quadrado de 9x9 células e aprova as que formam um círculo.
  • O loop varre só a caixa ao redor do centro, não o mapa inteiro. Com raio 4 são 81 testes, independente do tamanho do mapa.

Com a lista de células em mãos, a revelação simples (estilo dungeon crawler, sem shroud) é uma linha por célula:

func revelar_simples(celula_jogador: Vector2i) -> void:
    var no_raio: Array[Vector2i] = celulas_no_raio(celula_jogador, raio_visao)
    for celula: Vector2i in no_raio:
        fog_opaca.erase_cell(celula)

erase_cell remove o tile da célula, revelando o mapa por baixo. Células fora do mapa não têm tile, e apagar uma célula vazia não dá erro, então não precisa validar limites.

Se você quer uma transição menos dura entre revelado e escondido, uma alternativa barata é usar dois raios: apague a névoa no raio interno e coloque o tile de sombra no anel externo. Não é gradiente de verdade, mas suaviza a borda sem shader.

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

Shroud: a Sombra Que Volta Quando Você Sai

Para o comportamento de RTS, cada célula tem três estados possíveis, e as duas camadas de névoa representam isso sem nenhuma estrutura extra:

  • Nunca vista: tem tile no FogOpaca (preto total).
  • Já vista, fora de visão: sem tile no FogOpaca, com tile no FogSombra (sombra 55%).
  • Visível agora: sem tile nas duas camadas.

A lógica da atualização: tudo que estava visível no frame anterior volta para a sombra, depois tudo que está no raio atual fica limpo nas duas camadas.

func atualizar_visao(celula_jogador: Vector2i) -> void:
    for celula: Vector2i in celulas_visiveis:
        fog_sombra.set_cell(celula, FONTE_ID, TILE_NEVOA)
    celulas_visiveis.clear()

    var no_raio: Array[Vector2i] = celulas_no_raio(celula_jogador, raio_visao)
    for celula: Vector2i in no_raio:
        fog_opaca.erase_cell(celula)
        fog_sombra.erase_cell(celula)
        celulas_visiveis.append(celula)

A ordem importa: primeiro recobrimos as células antigas com sombra, depois limpamos as novas. Células que continuam dentro do raio são recobertas e limpas no mesmo frame, o que na prática não gera piscada nenhuma, porque tudo acontece antes de a tela desenhar.

Note que o fog_opaca.erase_cell dentro do loop é o que torna a revelação permanente: uma vez fora do preto, a célula nunca mais recebe tile na camada opaca, só alterna entre sombra e limpa.

Para vários exploradores (unidades de RTS, aliados), a mudança é pequena: acumule as células de todos os raios num único array antes de limpar, usando um Dictionary como conjunto para não duplicar célula:

func atualizar_visao_multi(unidades: Array[Node2D]) -> void:
    for celula: Vector2i in celulas_visiveis:
        fog_sombra.set_cell(celula, FONTE_ID, TILE_NEVOA)
    celulas_visiveis.clear()

    var conjunto: Dictionary = {}
    for unidade: Node2D in unidades:
        var celula_unidade: Vector2i = celula_de(unidade)
        for celula: Vector2i in celulas_no_raio(celula_unidade, raio_visao):
            conjunto[celula] = true

    for celula: Variant in conjunto.keys():
        var c: Vector2i = celula
        fog_opaca.erase_cell(c)
        fog_sombra.erase_cell(c)
        celulas_visiveis.append(c)

Atualizando Quando a Unidade Se Move (Sem Desperdiçar Frame)

Falta conectar tudo ao movimento. A tentação é chamar atualizar_visao todo frame no _physics_process, e funciona, mas é desperdício: o jogador atravessa uma célula de 32 pixels em vários frames, e a visão só muda quando a célula muda.

A otimização é guardar a última célula processada e só recalcular na troca:

func celula_de(no: Node2D) -> Vector2i:
    return fog_opaca.local_to_map(fog_opaca.to_local(no.global_position))


func _physics_process(_delta: float) -> void:
    var celula_atual: Vector2i = celula_de(jogador)
    if celula_atual != ultima_celula:
        ultima_celula = celula_atual
        atualizar_visao(celula_atual)

local_to_map converte uma posição local da camada em coordenada de célula, e o to_local antes dele garante que a conversão funcione mesmo que a camada esteja deslocada ou escalada. A comparação celula_atual != ultima_celula é o portão: enquanto o jogador anda dentro da mesma célula, nada é recalculado. O valor inicial absurdo de ultima_celula (Vector2i(-9999, -9999)) força a primeira atualização no primeiro frame.

Se o seu jogo tem movimento em grade (por turnos, por exemplo), fica ainda mais limpo: em vez de checar por frame, emita um sinal quando o movimento termina e conecte nele.

signal mudou_de_celula(nova_celula: Vector2i)


func _ao_mudar_de_celula(nova_celula: Vector2i) -> void:
    atualizar_visao(nova_celula)

Checagem por frame com portão de célula para movimento livre, sinal para movimento em grade. Os dois chegam no mesmo lugar: atualizar_visao só roda quando precisa.

Um teste rápido de sanidade que vale deixar num botão de debug:

func revelar_tudo() -> void:
    for celula: Vector2i in fog_opaca.get_used_cells():
        fog_opaca.erase_cell(celula)
    for celula: Vector2i in fog_sombra.get_used_cells():
        fog_sombra.erase_cell(celula)

E a Névoa Suave, Com Shader ou Light2D?

Sendo honesto: a versão por tiles tem borda quadrada. Para muitos jogos (estratégia em grade, roguelikes, táticos) isso é até desejável, porque comunica exatamente quais células você enxerga. Mas se você quer aquela névoa orgânica, com gradiente, existem dois caminhos:

  • Light2D + CanvasModulate: escureça a cena inteira com um CanvasModulate escuro e coloque um PointLight2D com textura radial seguindo o jogador. A visão atual fica suave e bonita com pouquíssimo esforço. O limite: luz não tem memória. Área explorada volta ao escuro total quando a luz sai, então isso resolve o "campo de visão", não o "mapa explorado".
  • Shader com textura de revelação: um ColorRect de tela cheia com um shader que lê uma textura onde cada pixel marca o quanto aquela região foi explorada. Você atualiza essa textura conforme o jogador anda (pintando com Image.set_pixel ou desenhando círculos num SubViewport) e o shader interpola tudo com suavidade. É o resultado mais bonito e o mais trabalhoso de manter.

Minha recomendação prática: comece pelos tiles. É o sistema mais simples de depurar, roda em qualquer hardware e o estado do jogo (célula explorada ou não) fica explícito, o que facilita salvar o progresso da névoa junto com o save. Migrar o visual para shader depois não muda a lógica de células que você construiu aqui.

Resumo e Próximos Passos

O sistema completo tem menos de 100 linhas: um TileMapLayer preto preenchido no _ready, uma função que lista células num raio usando distância ao quadrado, um segundo layer com modulate translúcido fazendo papel de shroud, e um portão de célula no _physics_process para não recalcular à toa. Com set_cell, erase_cell, get_used_cells e local_to_map você cobre os dois tipos clássicos de névoa de guerra.

Para continuar evoluindo o projeto: aplique a névoa num mapa gerado em runtime seguindo o tutorial de dungeon procedural na Godot, aprofunde o domínio de camadas e atlas no guia de TileMap na Godot 4, ou leve o sistema para escala maior no passo a passo de como fazer um jogo de estratégia RTS, onde fog of war encontra múltiplas unidades, visão de aliados e inteligência artificial. A névoa está pronta; agora esconda coisas interessantes atrás dela.

Perguntas frequentes

O que é fog of war em jogos?

Fog of war (névoa de guerra) é a mecânica que esconde partes do mapa que o jogador ainda não explorou ou que estão fora do alcance de visão das suas unidades. Ela cria tensão, incentiva exploração e é marca registrada de jogos de estratégia e dungeon crawlers.

Qual a diferença entre fog of war e shroud?

A névoa clássica esconde terreno que o jogador nunca viu. O shroud é a camada que volta a cobrir áreas já exploradas quando elas saem do campo de visão atual, geralmente como uma sombra semitransparente. RTS como Age of Empires usam os dois; muitos dungeon crawlers usam só a névoa permanente.

Como fazer fog of war na Godot 4 sem shader?

Crie um TileMapLayer extra por cima do mapa, preenchido com tiles pretos. Converta a posição do jogador em célula com local_to_map e apague os tiles num raio ao redor com erase_cell. Para o shroud, use um segundo TileMapLayer com modulate semitransparente que recobre células fora da visão.

TileMapLayer substituiu o TileMap na Godot 4?

Sim. A partir da Godot 4.3, o nó TileMap foi descontinuado e cada camada virou um nó TileMapLayer independente. Os métodos set_cell, erase_cell, get_used_cells e local_to_map continuam existindo, mas agora são chamados direto no TileMapLayer, sem o parâmetro de layer.

Fog of war por tiles pesa na performance?

Quase nada, se você só recalcular quando a unidade muda de célula. Comparar a célula atual com a última célula registrada é uma checagem barata por frame, e apagar ou recolocar algumas dezenas de tiles é trivial para a Godot. O erro comum é recalcular o raio inteiro todo frame sem necessidade.

Dá para fazer névoa de guerra suave, sem bordas quadradas?

Dá, usando Light2D com CanvasModulate ou um shader que lê uma textura de revelação. Fica mais bonito, mas exige manter uma textura atualizada com as áreas exploradas, o que é mais complexo. Para a maioria dos jogos 2D em grade, a versão por tiles resolve e é muito mais simples de manter.