[Pitch]: Como desenho toda a pixel art isométrica do meu jogo só com código Python (e o que aprendi fazendo isso com IA)
Estou fazendo um jogo em pixel art isométrica e não quis usar geradores de imagem por IA para criar os sprites. O resultado não seguia uma paleta consistente, variava de um sprite para outro e era difícil de corrigir pixel a pixel.
Então, a arte inteira virou código: um script Python com Pillow desenha cada prédio, personagem e efeito e gera um atlas com 514 frames, contando animações e variações de cor.
Neste post, vou mostrar como esse gerador funciona, com o código de verdade (simplificado em alguns pontos), e o que aprendi escrevendo isso com apoio de IA, usando o Claude Code.
No final, conto rapidamente qual é o jogo.
1. Paleta fechada: nenhuma cor fora da lista
A regra que mais ajudou foi esta: o código nunca usa uma cor RGB solta, só nomes de uma paleta de 70 cores.
Cada material tem uma rampa de tons, do mais escuro ao mais claro:
HEX = {
'ink': '1b1420', # contorno
$$
\TeX
$$
# Telhado vermelho
'r0': '4a1824',
'r1': '6b2431',
'r2': 'a0353a',
'r3': 'cc5544',
'r4': 'e88a6a',
# Madeira
'w0': '3a2219',
'w1': '4a2c22',
'w2': '6e4130',
'w3': '94603e',
'w4': 'b8834f',
# Pedra
's0': '2b2f3d',
's1': '3a3f4f',
's2': '555c70',
's3': '7b8299',
's4': 'a8afc2',
's5': 'd3d8e3',
# ... grama, terra, ouro, água, magia, pele, fogo
}
Com isso, tudo combina de graça. E dá para trocar uma rampa inteira por outra, como veremos na seção 4.
2. Uma caixa isométrica resolve quase tudo
O tile é 64×32, seguindo uma grade 2:1.
Quase todo prédio é uma ou mais caixas: a parede esquerda, a direita e o topo.
A classe Box recebe o vértice de baixo da base (B), a extensão para cada lado e a altura, e calcula os outros vértices:
class Box:
def __init__(self, B, a, b, h):
self.B = B
self.a, self.b, self.h = a, b, h
self.R = add(B, 2 * a, -a)
self.L = add(B, -2 * b, -b)
self.T = add(B, 2 * a - 2 * b, -a - b)
def up(self, p, z=None):
# O mesmo ponto, h pixels acima
return add(p, 0, -(self.h if z is None else z))
def draw_box(cv, bx, left, right, top):
L, B, R, T = bx.L, bx.B, bx.R, bx.T
cv.poly([L, B, bx.up(B), bx.up(L)], left)
cv.poly([B, R, bx.up(R), bx.up(B)], right)
cv.poly([bx.up(L), bx.up(B), bx.up(R), bx.up(T)], top)
A luz vem sempre da esquerda, então a face esquerda leva o tom mais claro da rampa e a direita, o mais escuro.
Como isso vale para todo o atlas, a iluminação nunca fica inconsistente.
3. Um prédio é uma função
Com a caixa, o telhado de duas águas e algumas janelas, uma casa de madeira fica assim:
def wooden_house(roof=('r1', 'r2', 'r3')):
cv, B = base_canvas(72, 80)
bx = centered_box(B, 12, 14)
# Paredes de madeira
draw_box(cv, bx, 'w3', 'w2', 'w4')
# Janelas
window(
cv, bx, 'L', 0.2, 6,
w=5, wh=5,
col='a1', frame='w4', light='a3'
)
# Porta
window(
cv, bx, 'R', 0.3, 0,
w=5, wh=9, col='w0'
)
dark, mid, light = roof
# Telhado
gable_roof(cv, bx, 11, mid, 'w4', dark, ridge_col=light)
# Chaminé
chamine = Box(add(bx.top_center(), 8, -2), 2, 2, 12)
draw_box(cv, chaminé, 's3', 's2', 's4')
return finish(cv, B) # contorno + âncora
O processo é incremental:
- Desenho a caixa.
- Adiciono as paredes, tábuas e janelas.
- Construo o telhado e a chaminé.
- Aplico o contorno.
- Troco a paleta do telhado para gerar uma variação.
O contorno de 1 px vem no fim, em um único passo. Todo pixel transparente que encosta em um pixel opaco vira ink.
def outline(img, col='ink'):
src, out = img.load(), img.copy()
o = out.load()
for y in range(img.height):
for x in range(img.width):
if src[x, y][3] == 0 and any(
0 <= x + dx < img.width
and 0 <= y + dy < img.height
and src[x + dx, y + dy][3] > 0
for dx, dy in (
(1, 0), (-1, 0), (0, 1), (0, -1)
)
):
o[x, y] = C(col)
return out
Assim, nenhum desenho precisa se preocupar individualmente com a borda.
4. Variações e skins de graça: trocar a paleta
Como cada cor tem um nome, recolorir um sprite é só um dicionário de mapeamento de cores.
Todas as skins do castelo — gelo, areia, sombrio, outono — e as variações de telhado dos prédios saem disso:
def remap(img, troca):
de = {PAL[a]: PAL[b] for a, b in troca.items()}
out = img.copy()
px = out.load()
for y in range(out.height):
for x in range(out.width):
if px[x, y] in de:
px[x, y] = de[px[x, y]]
return out
def rampa(a, b, n=5):
# Troca a rampa inteira: r0 -> p0, r1 -> p1...
return {f'{a}{k}': f'{b}{k}' for k in range(n)}
SKINS = {
'real': {
**rampa('r', 'p'),
**rampa('b', 'p'),
},
'outono': {
**rampa('r', 'f'),
**rampa('g', 'y'),
},
}
O contorno e as cores que não estão no mapeamento continuam iguais, então a skin não vaza para outros materiais.
Mudar a cor de um telhado em todos os prédios é mudar uma linha.
5. A âncora no atlas
Cada sprite tem um tamanho diferente, mas todos precisam pisar no tile no mesmo ponto.
O gerador guarda, para cada frame, onde fica o vértice de baixo do tile em proporção à imagem.
O atlas sai com um JSON no formato que o PixiJS lê diretamente:
'anchor': {
'x': round(ax / img.width, 5),
'y': round(ay / img.height, 5)
}
No jogo, feito com TypeScript e PixiJS 8, o tile (i, j) tem o vértice de cima em:
const x = (i - j) * 32;
const y = (i + j) * 16;
A ordem de desenho é:
const depth = (i + j) * 10 + camada;
Com a âncora certa, posicionar um prédio fica simples:
sprite.position.set(x, y);
Isso evita que sprites de dimensões diferentes pareçam flutuar ou afundar no cenário.
6. O que aprendi fazendo isso com IA
Programei o jogo com apoio do Claude Code, e o gerador de sprites foi o lugar onde isso mais mudou meu jeito de trabalhar.
Funciona melhor com regras fechadas
Paleta nomeada, luz sempre da esquerda e contorno automático: com essas regras escritas no projeto, os sprites novos já saíam coerentes com os antigos.
Sem elas, cada prédio vinha com um estilo diferente.
O ciclo é: desenha, olha, ajusta
A IA escreve a função, eu gero o atlas, olho a imagem ampliada e peço os ajustes necessários.
Por exemplo:
- "A chaminé está grudada no telhado."
- "A janela some no contorno."
Olhar o resultado é trabalho meu. Código de desenho que parece certo muitas vezes não está.
Testes seguram o resto do jogo
Fora a arte, as regras ficam em um motor puro, no formato (estado, ...) => novoEstado, com mais de 200 testes em Vitest.
Toda regra nova vem com teste. É assim que confiro o que a IA escreveu sem precisar ler cada linha com medo.
A IA não decide o produto
As regras do jogo, o que fica de fora e o tom dos textos são decisões que precisei tomar e reescrever várias vezes.
A IA acelera a implementação, mas não substitui as decisões de produto.
Sobre o jogo: Reino Financeiro
O gerador existe para o Reino Financeiro, um jogo de finanças pessoais.
Uma vez por mês, você informa quanto guardou, e o dinheiro constrói o seu reino, da tenda ao império.
O progresso é medido em meses de custo de vida guardados, e não em reais. Assim, a evolução é mais justa para pessoas com rendas diferentes.
O jogo é gratuito, roda no navegador, não acessa sua conta bancária e funciona sem precisar criar uma conta.
Acesse: https://financas.crudsolutions.com.br
Se houver interesse, posso escrever outro post sobre a outra metade do projeto: o motor puro com migração do estado salvo ou a sincronização local-first com o servidor.