3

[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:

  1. Desenho a caixa.
  2. Adiciono as paredes, tábuas e janelas.
  3. Construo o telhado e a chaminé.
  4. Aplico o contorno.
  5. 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.

Carregando publicação patrocinada...
1

A paleta e a luz aqui resolvem mais do que tentar explicar pra IA o estilo desejado toda vez. E o exemplo da chaminé vai direto ao ponto: o código pode parecer certo, mas ainda é preciso olhar a imagem.

1

Opa, tudo certo?! pode olhar a imagem direto no jogo mesmo acho que fica mais facil, tenho uns prints aqui mas não achei opcão de anexar imagens aqui.

1

hey man

Seja bem vindo ao tabnews!

Seu projeto parece interessante. Pelo que vi voce gosta de escrever código como o povo daqui da comunidade gosta. Eu entendo um pouco de python também.

Quero te mostrar que dá para 'formatar' o código postado:
use três crases

\`\`\`\python
seucodigo() #colorido
\`\`\`\
3