Como Fazer um Jogo de Damas na Godot 4: Tabuleiro, Captura e Promoção em GDScript

Aprenda a fazer um jogo de damas na Godot 4 com GDScript tipado: tabuleiro 8x8, captura obrigatória, promoção a dama e fim de jogo, passo a passo.
O jogo de damas é um dos melhores projetos para quem já sabe o básico de GDScript e quer sair dos tutoriais de "mova o personagem". As regras estão prontas há mais de um século, a arte se resolve com dois retângulos coloridos e todo o desafio está onde deveria estar: representar o tabuleiro em memória, gerar movimentos legais e aplicar a captura obrigatória. Neste tutorial, vamos montar uma dama brasileira funcional na Godot 4, com tipagem estática em todos os trechos, do array 8x8 até a detecção de fim de jogo.
A filosofia é a mesma que usamos no guia de jogo de tabuleiro digital: a lógica vive em dados, o visual é só um espelho. Se este projeto ainda parecer grande para o seu momento, faça primeiro um jogo da velha, que ensina a mesma estrutura em escala menor, e volte aqui em seguida.
Como fazer um jogo de damas: as regras que o código vai implementar
Antes de abrir a engine, deixe as regras da dama brasileira explícitas, porque cada uma vira uma função:
- Tabuleiro 8x8, jogo acontece só nas casas escuras. Cada lado começa com 12 pedras nas três fileiras mais próximas.
- A pedra anda uma casa na diagonal, sempre para frente.
- A captura é por salto: a pedra pula sobre a peça inimiga adjacente e pousa na casa vazia logo atrás dela. Na regra brasileira, a pedra captura também para trás.
- Se existe captura disponível, capturar é obrigatório.
- Pedra que chega à última fileira vira dama. A dama anda quantas casas quiser na diagonal, para frente e para trás.
- Perde quem ficar sem peças ou sem nenhum movimento legal.
Capturas em cadeia (dois ou mais saltos na mesma jogada) fazem parte da regra oficial, mas vamos deixá-las como exercício no final. O jogo funciona sem elas e o código fica bem mais fácil de acompanhar.
Representando o tabuleiro em memória
O estado inteiro do jogo cabe em uma matriz 8x8 de inteiros. Crie uma cena com um Node2D na raiz, anexe um script e comece pelas constantes:
extends Node2D
const TAMANHO_CASA: int = 64
const TAMANHO_TABULEIRO: int = 8
enum Casa { VAZIO, PEDRA_BRANCA, PEDRA_PRETA, DAMA_BRANCA, DAMA_PRETA }
const DIAGONAIS: Array[Vector2i] = [
Vector2i(-1, -1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(1, 1)
]
var tabuleiro: Array[Array] = []
var turno_branco: bool = true
var casa_selecionada: Vector2i = Vector2i(-1, -1)
var movimentos_atuais: Array[Dictionary] = []
var camada_pecas: Node2D
Um detalhe de GDScript: a tipagem aninhada Array[Array[int]] não existe, então o tabuleiro é Array[Array] e cada linha interna é um Array[int]. A convenção de acesso será sempre tabuleiro[lin][col], e um Vector2i de casa guarda coluna em x e linha em y.
As pretas começam nas linhas 0 a 2 (topo da tela) e as brancas nas linhas 5 a 7. Casa escura é aquela em que linha mais coluna dá ímpar:
func _ready() -> void:
camada_pecas = Node2D.new()
_criar_tabuleiro()
_posicionar_pecas()
_desenhar_tabuleiro()
add_child(camada_pecas)
_atualizar_visual()
func _criar_tabuleiro() -> void:
tabuleiro.clear()
for lin: int in range(TAMANHO_TABULEIRO):
var linha: Array[int] = []
for col: int in range(TAMANHO_TABULEIRO):
linha.append(Casa.VAZIO)
tabuleiro.append(linha)
func _posicionar_pecas() -> void:
for lin: int in range(TAMANHO_TABULEIRO):
for col: int in range(TAMANHO_TABULEIRO):
if (lin + col) % 2 == 1:
if lin < 3:
tabuleiro[lin][col] = Casa.PEDRA_PRETA
elif lin > 4:
tabuleiro[lin][col] = Casa.PEDRA_BRANCA
Repare que camada_pecas entra na árvore depois das casas do tabuleiro, para as peças ficarem desenhadas por cima.
Desenhando o tabuleiro sem TileMap
Para um grid fixo de 64 casas, gerar ColorRect por código é o caminho mais direto. A conta de posição é a alma do tutorial inteiro: a casa da coluna col e linha lin fica em Vector2(col, lin) * TAMANHO_CASA.
func _desenhar_tabuleiro() -> void:
for lin: int in range(TAMANHO_TABULEIRO):
for col: int in range(TAMANHO_TABULEIRO):
var casa: ColorRect = ColorRect.new()
casa.size = Vector2(TAMANHO_CASA, TAMANHO_CASA)
casa.position = Vector2(col, lin) * TAMANHO_CASA
if (lin + col) % 2 == 0:
casa.color = Color("f0d9b5")
else:
casa.color = Color("6b4a2f")
add_child(casa)
As peças seguem a mesma lógica, mas são redesenhadas a cada jogada. Apagar tudo e recriar é ineficiente para um jogo de ação, mas para um jogo de tabuleiro com uma jogada a cada poucos segundos é perfeito, porque elimina qualquer chance do visual dessincronizar do array:
func _atualizar_visual() -> void:
for filho: Node in camada_pecas.get_children():
filho.queue_free()
for lin: int in range(TAMANHO_TABULEIRO):
for col: int in range(TAMANHO_TABULEIRO):
var valor: int = tabuleiro[lin][col]
if valor == Casa.VAZIO:
continue
var peca: ColorRect = ColorRect.new()
peca.size = Vector2(TAMANHO_CASA - 16, TAMANHO_CASA - 16)
peca.position = Vector2(col, lin) * TAMANHO_CASA + Vector2(8, 8)
match valor:
Casa.PEDRA_BRANCA:
peca.color = Color.WHITE
Casa.PEDRA_PRETA:
peca.color = Color.BLACK
Casa.DAMA_BRANCA:
peca.color = Color.GOLD
Casa.DAMA_PRETA:
peca.color = Color.DARK_RED
camada_pecas.add_child(peca)
Quando tiver o jogo rodando, troque os ColorRect das peças por Sprite2D com textura: a conta de posição é a mesma. Cor diferente para a dama já resolve a leitura no protótipo.
Selecionando a peça com o clique
Converter mouse em casa é a operação inversa do desenho: divida a posição pelo tamanho da casa e trunque para inteiro.
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
var pos: Vector2 = get_local_mouse_position()
var col: int = int(pos.x / TAMANHO_CASA)
var lin: int = int(pos.y / TAMANHO_CASA)
if col < 0 or col >= TAMANHO_TABULEIRO or lin < 0 or lin >= TAMANHO_TABULEIRO:
return
_tratar_clique(Vector2i(col, lin))
func _tratar_clique(casa: Vector2i) -> void:
var valor: int = tabuleiro[casa.y][casa.x]
var minha_peca: bool = (turno_branco and _eh_branca(valor)) or (not turno_branco and _eh_preta(valor))
if minha_peca:
casa_selecionada = casa
movimentos_atuais = _movimentos_legais(casa)
return
if casa_selecionada == Vector2i(-1, -1):
return
for movimento: Dictionary in movimentos_atuais:
if movimento["destino"] == casa:
_executar_movimento(casa_selecionada, movimento)
return
O fluxo é o clássico de jogo de tabuleiro: primeiro clique seleciona uma peça do turno atual e guarda os movimentos legais dela; segundo clique, se cair em um destino válido, executa a jogada. Clicar em outra peça sua apenas troca a seleção.
Algumas funções de apoio que o resto do código vai usar o tempo todo:
func _eh_branca(valor: int) -> bool:
return valor == Casa.PEDRA_BRANCA or valor == Casa.DAMA_BRANCA
func _eh_preta(valor: int) -> bool:
return valor == Casa.PEDRA_PRETA or valor == Casa.DAMA_PRETA
func _eh_dama(valor: int) -> bool:
return valor == Casa.DAMA_BRANCA or valor == Casa.DAMA_PRETA
func _sao_inimigas(a: int, b: int) -> bool:
return (_eh_branca(a) and _eh_preta(b)) or (_eh_preta(a) and _eh_branca(b))
func _dentro(casa: Vector2i) -> bool:
return casa.x >= 0 and casa.x < TAMANHO_TABULEIRO and casa.y >= 0 and casa.y < TAMANHO_TABULEIRO
Gerando os movimentos legais
Cada movimento será um Dictionary com duas chaves: destino, a casa de pouso, e capturada, a casa da peça comida. Movimento simples usa Vector2i(-1, -1) em capturada como valor sentinela.
Movimentos da pedra
A pedra anda para frente e captura em qualquer diagonal. "Frente" depende da cor: brancas sobem na tela (linha diminui), pretas descem.
func _movimentos_de_pedra(origem: Vector2i) -> Array[Dictionary]:
var movimentos: Array[Dictionary] = []
var valor: int = tabuleiro[origem.y][origem.x]
var direcao_frente: int = -1 if _eh_branca(valor) else 1
for diagonal: Vector2i in DIAGONAIS:
var vizinha: Vector2i = origem + diagonal
if not _dentro(vizinha):
continue
var valor_vizinha: int = tabuleiro[vizinha.y][vizinha.x]
if valor_vizinha == Casa.VAZIO:
if diagonal.y == direcao_frente:
movimentos.append({"destino": vizinha, "capturada": Vector2i(-1, -1)})
elif _sao_inimigas(valor, valor_vizinha):
var pouso: Vector2i = origem + diagonal * 2
if _dentro(pouso) and tabuleiro[pouso.y][pouso.x] == Casa.VAZIO:
movimentos.append({"destino": pouso, "capturada": vizinha})
return movimentos
Note como a regra brasileira simplifica o código: o teste de sentido (diagonal.y == direcao_frente) só existe no movimento simples. O salto de captura roda nas quatro diagonais sem restrição.
Movimentos da dama
A dama desliza pela diagonal até esbarrar em algo. Se esbarrar em uma inimiga com casas livres depois dela, cada casa livre é um pouso de captura válido. Duas peças na mesma diagonal bloqueiam o caminho.
func _movimentos_de_dama(origem: Vector2i) -> Array[Dictionary]:
var movimentos: Array[Dictionary] = []
var valor: int = tabuleiro[origem.y][origem.x]
for diagonal: Vector2i in DIAGONAIS:
var atual: Vector2i = origem + diagonal
var capturada: Vector2i = Vector2i(-1, -1)
while _dentro(atual):
var valor_atual: int = tabuleiro[atual.y][atual.x]
if valor_atual == Casa.VAZIO:
movimentos.append({"destino": atual, "capturada": capturada})
elif _sao_inimigas(valor, valor_atual) and capturada == Vector2i(-1, -1):
capturada = atual
else:
break
atual += diagonal
return movimentos
O truque está na variável capturada: enquanto ela é o sentinela, os pousos são movimentos simples. Depois que o laço passa por uma inimiga, todo pouso seguinte carrega a casa dela como captura. Uma segunda peça qualquer no caminho encerra o while.
Captura obrigatória
A regra mais importante das damas é um filtro. Antes de devolver os movimentos de uma peça, pergunte se o jogador do turno tem alguma captura em qualquer peça. Se tiver, só capturas são legais:
func _movimentos_da_peca(origem: Vector2i) -> Array[Dictionary]:
var valor: int = tabuleiro[origem.y][origem.x]
if valor == Casa.VAZIO:
return []
if _eh_dama(valor):
return _movimentos_de_dama(origem)
return _movimentos_de_pedra(origem)
func _existe_captura_para(brancas: bool) -> bool:
for lin: int in range(TAMANHO_TABULEIRO):
for col: int in range(TAMANHO_TABULEIRO):
var valor: int = tabuleiro[lin][col]
if valor == Casa.VAZIO or brancas != _eh_branca(valor):
continue
for movimento: Dictionary in _movimentos_da_peca(Vector2i(col, lin)):
if movimento["capturada"] != Vector2i(-1, -1):
return true
return false
func _movimentos_legais(origem: Vector2i) -> Array[Dictionary]:
var movimentos: Array[Dictionary] = _movimentos_da_peca(origem)
if not _existe_captura_para(turno_branco):
return movimentos
var capturas: Array[Dictionary] = []
for movimento: Dictionary in movimentos:
if movimento["capturada"] != Vector2i(-1, -1):
capturas.append(movimento)
return capturas
Varrer o tabuleiro inteiro a cada seleção parece caro, mas são no máximo 64 casas com meia dúzia de testes cada: irrelevante para a CPU e muito mais fácil de depurar do que qualquer cache esperto.
Executando o movimento e promovendo a dama
Executar a jogada é atualizar o array em três passos: mover, remover a capturada, checar promoção. Depois, trocar o turno e redesenhar.
func _executar_movimento(origem: Vector2i, movimento: Dictionary) -> void:
var destino: Vector2i = movimento["destino"]
var capturada: Vector2i = movimento["capturada"]
tabuleiro[destino.y][destino.x] = tabuleiro[origem.y][origem.x]
tabuleiro[origem.y][origem.x] = Casa.VAZIO
if capturada != Vector2i(-1, -1):
tabuleiro[capturada.y][capturada.x] = Casa.VAZIO
_checar_promocao(destino)
casa_selecionada = Vector2i(-1, -1)
movimentos_atuais.clear()
turno_branco = not turno_branco
_atualizar_visual()
_checar_fim_de_jogo()
func _checar_promocao(casa: Vector2i) -> void:
var valor: int = tabuleiro[casa.y][casa.x]
if valor == Casa.PEDRA_BRANCA and casa.y == 0:
tabuleiro[casa.y][casa.x] = Casa.DAMA_BRANCA
elif valor == Casa.PEDRA_PRETA and casa.y == TAMANHO_TABULEIRO - 1:
tabuleiro[casa.y][casa.x] = Casa.DAMA_PRETA
Como a dama é só outro valor no enum, a promoção não cria objeto nenhum: troca um inteiro no array, e na próxima jogada _movimentos_da_peca passa a chamar _movimentos_de_dama para aquela casa. É o tipo de elegância que a representação por matriz compra de graça.
Fim de jogo: sem peças ou sem movimento
As duas condições de derrota da dama brasileira colapsam em uma só verificação: quem não tem nenhum movimento legal perdeu. Jogador sem peças não tem movimento por definição, então um único laço cobre os dois casos.
func _tem_movimento(brancas: bool) -> bool:
for lin: int in range(TAMANHO_TABULEIRO):
for col: int in range(TAMANHO_TABULEIRO):
var valor: int = tabuleiro[lin][col]
if valor == Casa.VAZIO or brancas != _eh_branca(valor):
continue
if _movimentos_da_peca(Vector2i(col, lin)).size() > 0:
return true
return false
func _checar_fim_de_jogo() -> void:
if _tem_movimento(turno_branco):
return
var vencedor: String = "pretas" if turno_branco else "brancas"
print("Fim de jogo. Vitoria das %s." % vencedor)
set_process_unhandled_input(false)
Aqui basta checar os movimentos brutos, sem o filtro de captura obrigatória: se o jogador tem qualquer movimento, tem pelo menos um legal. O set_process_unhandled_input(false) congela o jogo; troque o print por um Label de vitória quando for polir a interface.
Com isso o jogo está de pé: duas pessoas no mesmo teclado já jogam uma partida completa, com captura obrigatória, promoção e vitória detectada.
Próximos passos: capturas em cadeia e uma IA simples
Dois exercícios levam este projeto ao nível seguinte.
O primeiro é a captura em cadeia. Na regra oficial, se depois de um salto a mesma peça pode capturar de novo, o turno continua com ela. A mudança no código é localizada: em _executar_movimento, quando capturada não é o sentinela, gere os movimentos da peça no destino e filtre só as capturas. Se a lista não estiver vazia, não troque o turno; em vez disso, force casa_selecionada = destino e deixe movimentos_atuais apenas com as capturas. É um ótimo teste de quanto você entendeu o fluxo de turno.
O segundo é um oponente artificial. Uma IA honesta para começar tem três linhas de estratégia: liste todos os movimentos legais das pretas, prefira qualquer captura, senão jogue um movimento aleatório com movimentos.pick_random(). Isso já produz um adversário que pune seus descuidos, porque a captura obrigatória faz metade do trabalho tático por ele. Minimax e avaliação de posição ficam para depois; se quiser ver esse caminho em um jogo de regras mais ricas, o tutorial de jogo de xadrez na Godot discute exatamente essa progressão.
O que você levou deste projeto vale para qualquer jogo por turnos: estado em matriz, geração de movimentos como dados, regras como filtros e visual como espelho do array. Damas é pequeno o bastante para terminar em um fim de semana e completo o bastante para ensinar a estrutura que você vai reutilizar pelo resto da sua vida de desenvolvedor.
Perguntas frequentes
Preciso usar TileMap para fazer um jogo de damas na Godot?
Não. Para um tabuleiro fixo de 8x8, gerar 64 ColorRect por código é mais simples e você controla tudo com um único loop. TileMapLayer compensa quando o mapa é grande ou usa arte em tiles. Em damas, a lógica vive em um array de 8x8 e o visual é só um espelho dele.
Como converter o clique do mouse em uma casa do tabuleiro de damas?
Pegue a posição do mouse relativa ao nó do tabuleiro com get_local_mouse_position(), divida pelo tamanho da casa em pixels e converta para int. O resultado é a coluna e a linha clicadas. Antes de usar, valide se os dois valores estão entre 0 e 7.
Como funciona a captura obrigatória na dama brasileira?
Se qualquer peça sua pode capturar, você é obrigado a capturar: movimentos simples deixam de ser legais naquele turno. No código, isso vira um filtro. Antes de listar os movimentos de uma peça, verifique se existe alguma captura disponível para o jogador. Se existir, devolva apenas os movimentos que capturam.
A pedra pode capturar para trás no jogo de damas?
Na regra brasileira, sim. A pedra só anda para frente, mas captura por salto em qualquer uma das quatro diagonais, inclusive para trás. Isso simplifica o código: o salto de captura testa as quatro direções e a restrição de sentido só se aplica ao movimento simples.
Damas é um bom projeto para iniciante na Godot?
É um dos melhores segundos projetos. As regras são conhecidas, a arte é dispensável e o desafio é puro estado e lógica: matriz, validação de movimento e turnos. Se ainda for grande demais, comece com um jogo da velha e volte para as damas com essa base pronta.


