Design Técnico — Blocky Bee¶
Rastreabilidade: cada seção referencia os requisitos (R1–R33) de requirements.md. A ligação completa critério → seção → tarefa → teste está em traceability.md, verificada por tests/test_traceability.py (R32).
As seções 1–18 (Parte I) descrevem o jogo base, herdado da v1 com os ajustes que o Android exigiu, sinalizados como "(v2)" no texto. As seções 19–25 (Parte II) são inteiramente novas na v2, cobrindo Android. As seções 26–29 (Parte III) são aumentos de escopo posteriores da v2, adicionados após a entrega Android: conformidade com ruff, calibração do tamanho de fonte, conformidade com ty e o ícone do aplicativo. As seções 30–43 (Parte V) são inteiramente novas na v3: identidade, canvas lógico com faixas decorativas, render acelerado por GPU, atlas pré-renderizado, disciplina de alocação, timestep fixo, qualidade adaptativa e a camada de documentação/rastreabilidade.
Onde a Parte I ficou desatualizada pela v3. As seções 1–18 continuam descrevendo corretamente as regras do jogo, mas dois pontos foram substituídos e estão marcados no texto da Parte V: (a) o desenho deixou de receber uma
pygame.Surfacee passou a receber o renderizador da seção 33; (b) os comentários das seções 7 e 17 que falam em "o canvas real no Android pode ser maior que 480×720" descreviam uma hipótese da v2 quepygame.SCALEDnunca realizou — na v3 isso passa a ser verdade de fato, mas por outro mecanismo (seção 31), e a área jogável permanece fixa.
Parte I — Jogo base¶
1. Visão geral¶
Jogo 2D em Python 3.10+ / pygame-ce 2.5+, loop de jogo com timestep fixo a 60 FPS (R9). Arquitetura orientada a objetos com máquina de estados simples e separação entre lógica, renderização e áudio.
v2 — alvo duplo desktop + Android. O mesmo código-fonte roda em Windows/Linux (executável PyInstaller, R13) e em Android celular/tablet, sempre em retrato (APK Buildozer, R17). A estratégia para isso é manter todo o jogo escrito contra uma resolução lógica fixa de 480×720 e uma camada de ações abstratas de input, empurrando as diferenças de plataforma para três pontos isolados:
| Diferença | Onde é resolvida |
|---|---|
| Tela de tamanho/proporção arbitrária | pygame.SCALED no set_mode, escala + letterbox automáticos (seção 20) |
| Toque, BACK do Android | input.py, traduzidos para as mesmas ações abstratas já existentes (seção 21) |
| Onde se pode gravar arquivo | storage.py, resolve o diretório por plataforma (seção 23) |
Nenhum módulo de gameplay (bird, pipes, biome, score, particles, decor, ground) precisa saber em que plataforma está rodando.
2. Estrutura do projeto¶
flappy_bird/
├── pyproject.toml # Projeto gerenciado por uv (R9.3)
├── uv.lock # Lockfile gerado por uv
├── main.py # Entry point: cria Game e roda o loop (R9.3)
├── BlockyBee.spec # Config do PyInstaller p/ executavel standalone (R13.1, icone R21.3)
├── buildozer.spec # Config do Buildozer p/ APK Android (R17.1, icone R21.4-6)
├── p4a-recipes/
│ └── pygame-ce/ # Receita local de build do pygame-ce p/ p4a (sec. 24) [novo na v2]
├── assets/ # PNGs/ICO gerados por script (icones) [icones: sec. 29]
│ ├── app_icon.ico
│ ├── app_icon_512.png
│ ├── android_icon_legacy.png
│ ├── android_icon_foreground.png
│ └── android_icon_background.png
├── scripts/
│ └── generate_app_icon.py # Gera todas as variacoes do icone (sec. 29)
├── .github/workflows/
│ └── release.yml # CI: builda e publica executaveis + APK na Release (R13.2, R17.2)
├── specs/ # Specs versionadas (esta pasta é specs/v2/, ver specs/README.md)
└── src/
├── __init__.py
├── config.py # Constantes: tela, física, biomas, cores, créditos
├── game.py # Classe Game: loop, máquina de estados (R6)
├── bird.py # Classe Bird: física e animação (R1)
├── pipes.py # PipePair + PipeManager (R2)
├── ground.py # Chão rolante de blocos (R7.3)
├── biome.py # Definições e transição de biomas (R5)
├── decor.py # Parallax de fundo por bioma (R7.4)
├── score.py # Pontuação e persistência do recorde (R4)
├── storage.py # Resolve diretório gravável por plataforma (R4.5) [novo na v2]
├── assets.py # Resolve caminho de asset bundlado (fonte/empacotado) (R21.2)
├── particles.py # Sistema de partículas de blocos (R3.2)
├── textures.py # Geração procedural de texturas voxel (R7)
├── pixelfont.py # Fonte bitmap gerada por código (R7.6) [novo na v2]
├── scale.py # fit_scale: letterbox p/ toque, sem cortar imagem (R14.3)
├── input.py # InputManager: teclado, mouse, gamepad, toque (R10, R15)
├── sounds.py # Síntese de sons 8-bit (R8)
└── ui.py # HUD, telas PRONTO/PAUSADO/GAME_OVER, fonte pixelada (R6, R7.5, R11, R12, R36)
2.1 Gerenciamento com uv (R9.3)¶
Projeto inicializado com uv init; pygame-ce>=2.5 declarado como dependência de runtime (R9.2), pytest e pyinstaller como dependências de dev no pyproject.toml (R13.1). Comandos padrão:
uv sync # cria/atualiza o ambiente
uv run main.py # executa o jogo
uv run pytest # roda os testes
uv run pyinstaller BlockyBee.spec # gera o executavel standalone (R13.1)
O APK não é gerado por uv — o Buildozer roda em container Docker Linux (seção 24), tanto no CI quanto localmente.
Migração
pygame→pygame-ce(v2).pygame-ceé um fork mantido pela comunidade, compatível a nível de API e importado igualmente comoimport pygame. A troca é feita apenas nopyproject.toml(pygame>=2.5→pygame-ce>=2.5); nenhumimportmuda. O motivo é que a cadeia de build Android escolhida (python-for-android) tem receita parapygame-ce, não para opygameupstream. Os dois pacotes instalam o mesmo módulopygamee não podem coexistir no mesmo ambiente — ouv syncapós a troca já resolve isso sozinho (confirmado: desinstala um, instala o outro, sem precisar de--reinstall).Efeito colateral encontrado (task 26):
pygame-cetrouxe SDL 2.32.10 (a v1 rodava com SDL 2.28.4, empacotado junto dopygameantigo). No SDL novo,pygame.display.set_mode(..., SCALED)sobSDL_VIDEODRIVER=dummypassou a enfileirar uma sequência de eventos de janela na criação (WindowShown,WindowFocusGained/WindowFocusLost,ActiveEvent...) que não existia antes — incluindo umWindowFocusLostgenuíno, que a v2 acabara de passar a tratar como pausa automática (task 24). Sem tratamento, isso contaminava o primeiropoll()de qualquer teste com uma açãofocus_lostespúria. Confirmado com um driver de vídeo real que essa sequência de eventos não incluiWindowFocusLost— é um artefato específico do driverdummyusado só nos testes, não afeta jogadores de verdade. Corrigido compygame.event.clear()logo após oset_mode(), tanto emGame.__init__()(defensivo) quanto nos testes que criam janela diretamente.
Nenhum pip install ou venv manual — todo o fluxo passa pelo uv instalado localmente.
3. Máquina de estados (R6)¶
PRONTO ──flap──▶ JOGANDO ──colisão──▶ GAME_OVER ──flap──▶ PRONTO
▲ │
ESC/P ESC/P
│ ▼
PAUSADO
Game.state: GameState (Enum). Cada estado tem handle_events, update, draw. Em PAUSADO e GAME_OVER, update de física/obstáculos não roda (R3.3, R6.3).
4. Configuração (config.py)¶
SCREEN_W, SCREEN_H = 480, 720 # resolução LÓGICA; a tela real pode ser qualquer uma (R9.1, R14.3)
FPS = 60
TITLE = "Blocky Bee"
CREDITS = "por Douglas e Pedro" # R11
GRAVITY = 0.45 # px/frame²
FLAP_IMPULSE = -8.5 # px/frame
MAX_FALL_SPEED = 12
GROUND_H = 96
PIPE_SPACING = 260 # distância horizontal entre pares
BLOCK = 48 # tamanho do bloco renderizado (16×16 escalado 3×)
PIPE_W = BLOCK # largura da coluna = largura do bloco desenhado (evita hitbox maior que o sprite)
HITBOX_SCALE = 0.85 # R3.5
CORNER_TOLERANCE = 4 # px de sobreposicao minima nos dois eixos para contar colisao (R3.6)
Valores de física são referência inicial; calibrar em playtest (task 12).
5. Bird (bird.py) — R1¶
- Atributos:
pos: Vector2,vel_y: float,angle: float,frame: int. flap():vel_y = FLAP_IMPULSE; toca som flap; setaangle = +30.update():vel_y = min(vel_y + GRAVITY, MAX_FALL_SPEED);pos.y += vel_y; clamp no topo (pos.y >= 0, R1.4); interpolaangleaté −60 durante queda (R1.3).rect(hitbox): sprite rect escalado porHITBOX_SCALEcentralizado (R3.5). É sempre um quadrado reto — nunca acompanhaangle, mesmo com o sprite desenhado girando de +30° a −60° (ver seção 6 para a tolerância de canto que compensa essa divergência, R3.6).- Animação: alterna 2 frames de asa a cada 6 frames de jogo; no estado PRONTO faz bobbing senoidal (R6.1).
- Sprite: abelha voxel 16×12 desenhada pixel a pixel em
textures.make_bee()— corpo amarelo com listras pretas, asas cinza translúcido (R7.2).
6. Pipes (pipes.py) — R2¶
class PipePair:
x: float
gap_y: float # centro da abertura
gap_size: int # do bioma, congelado na criação (R2.2, R2.5)
block_main: str # chave em textures, congelada na criação (R2.5)
block_edge: str # chave em textures, congelada na criação (R2.5)
scored: bool # p/ pontuação única (R4.1)
PipeManager.update(speed, gap_size, block_main, block_edge): recebe os parâmetros do bioma atual a cada frame — move todosx -= speed(R2.3); spawna novo par (congelandogap_size/block_main/block_edgecorrentes) quando o último está aPIPE_SPACINGda borda (R2.1); remove pares comx + PIPE_W < 0(R2.4).gap_yaleatório uniforme entre margens seguras (topo +GAP_MARGIN, chão −GAP_MARGIN) (R2.2).- Renderização: coluna = pilha de blocos
BLOCK×BLOCK(larguraPIPE_W = BLOCK, R3.1/R3.5) com a textura congelada na criação; bloco da boca da abertura usa variante de borda (ex.: grama no Overworld) (R2.5). - Colisão: dois
Rectpor par (superior e inferior), larguraPIPE_Widêntica à largura desenhada (R3.1).
Tolerância de canto (R3.6, ajuste pós-lançamento da v3). Game._collision_texture não usa mais bird.rect.colliderect() puro — a checagem (_collides, módulo game.py) só conta colisão quando a sobreposição é ≥ CORNER_TOLERANCE px nos dois eixos (x e y), calculada por aritmética pura sobre .left/.right/.top/.bottom, sem construir nenhum pygame.Rect novo (o mesmo orçamento de zero alocação de _collision_texture que a seção 36 documenta e tests/test_alloc.py::test_the_collision_check_builds_no_rectangle_at_all fixa).
Motivo: bird.rect é reto (seção 5), mas o sprite desenhado gira — a caixa reta "sobra" além do contorno visível da abelha justamente nas diagonais, que é onde ficam os cantos internos do vão de uma coluna e a quina do chão. Sem tolerância, 1px de sobreposição em qualquer eixo já matava, então um resvalar raso de canto (fundo num eixo, raso no outro — a marca desse descompasso reto-vs-rotação) matava sem o jogador achar que encostou. Uma batida de frente invade os dois eixos rápido e continua matando na mesma velocidade de sempre; só o resvalar raso passa a ser perdoado.
Alternativa descartada: rotacionar bird.rect junto com angle (hitbox orientada/OBB). Resolveria a causa raiz de forma mais exata, mas trocaria uma comparação de retângulos alinhados aos eixos — barata, testável por aritmética simples — por geometria de polígono rotacionado, sem ganho perceptível sobre a tolerância fixa para o problema relatado (resvalar em canto, não uma imprecisão generalizada de ângulo).
7. Biomas (biome.py) — R5¶
@dataclass(frozen=True)
class Biome:
id: str; name: str
threshold: int # pontuação de ativação (R5.1)
speed: float # R5.3
gap_size: int # R5.3
sky_top: Color; sky_bottom: Color
block_main: str; block_edge: str # chaves em textures
decor: str # "overworld" | "cave" | "nether" (R7.4)
BIOMES = [
Biome("overworld", "Overworld", 0, 2.5, 160, ...),
Biome("cave", "Cave", 10, 3.0, 145, ...),
Biome("nether", "Nether", 25, 3.3, 140, ...),
]
Calibrado na task 12 (R5.3, R9.1): a velocidade/abertura do Nether foram ajustadas de (3.5, 130px) para (3.3, 140px) apos playtest automatizado mostrar um salto de dificuldade desproporcional na transicao Cave→Nether (bot competente sobrevivia ~12.7 pontos extras apos entrar no Cave, mas so ~2.4 apos entrar no Nether). Com os novos valores o Nether permanece o bioma mais dificil, porem navegavel (~11 pontos extras em media), preservando a curva de dificuldade progressiva.
BiomeManager.update(score): detecta cruzamento de threshold → inicia fade de 60 frames entre gradientes de céu (R5.4), mostra banner com nome do bioma por 90 frames, toca som de portal (R8.1).- Colunas já existentes mantêm textura antiga; novas usam o bioma novo (transição natural).
- Decoração parallax (R7.4): duas camadas com fatores 0.3 e 0.6 da velocidade de rolagem; elementos desenhados proceduralmente como pilhas de retângulos (formas quadriculadas/voxel, nunca elipses ou curvas) — nuvens/colinas no Overworld, estalactites/minérios no Cave, lava/pilares no Nether. Detalhes em
decor.py(seção 16).
8. Pontuação (score.py) — R4¶
+1quandopipe.x + PIPE_W < bird.pos.xenot pipe.scored(R4.1); toca som XP.- Persistência:
highscore.jsonno diretório resolvido porstorage.py(seção 23, R4.5) — conteúdo{"highscore": int}. Leitura comtry/except (OSError, ValueError, KeyError, TypeError)→ fallback 0 (R4.4). - Gravação incremental (v2, R4.3/R16.4): em vez de salvar só no GAME_OVER, o
Gamegrava no instante em que o score da partida em curso ultrapassa o recorde. Como isso acontece no máximo uma vez por partida (a partir daíhighscoreacompanha o score), não há custo de I/O por frame:
# em Game._update_score(), ao incrementar:
if self.score > self.highscore:
self.highscore = self.score
score.save_highscore(self.highscore) # 1 gravação por ponto acima do recorde
Isso garante que um app encerrado pelo Android no meio da partida não perca o recorde já alcançado.
9. Partículas (particles.py) — R3.2¶
Particle: pos, vel (explosão radial + gravidade), lifetime 20–40 frames, quad 4–8 px com cor amostrada da textura do bloco atingido.ParticleSystem.burst(pos, texture, n=16)chamado na colisão. Atualiza/desenha mesmo em GAME_OVER (efeito termina naturalmente enquanto obstáculos ficam congelados).
10. Texturas procedurais (textures.py) — R7¶
- Todas geradas uma vez no init, em
Surface16×16 com ruído determinístico (random.Random(seed)), depoispygame.transform.scaleparaBLOCKcom vizinho-mais-próximo (pixel perfeito) (R7.1). - Paletas por bloco:
dirt,grass_side,stone,cobblestone,netherrack,obsidian, maisbeee elementos de decoração. - Técnica: cor base + variação aleatória de brilho por pixel (±12%), padrões específicos (grama: faixa verde no topo; obsidiana: manchas roxas).
11. Áudio (sounds.py) — R8¶
- Síntese apenas com stdlib (
array+math+random), semnumpy: R9.2 restringe as dependências do projeto apygame+ stdlib, então a síntese gera o buffer PCM (16-bit signed, mono, 44100 Hz) manualmente e entrega viapygame.mixer.Sound(buffer=...). flap: onda quadrada com sweep 300→500 Hz e decaimento linear, 80 ms.score: dois pings de onda quadrada 800/1200 Hz em sequência, 120 ms (estilo XP orb).hit: ruído branco com decaimento, 200 ms.portal: onda quadrada com sweep descendente 900→200 Hz, 400 ms.pygame.mixer.init(frequency=44100, size=-16, channels=1)emtry/except→ flagaudio_ok; toda chamada deplay()checaaudio_okemuted(R8.3, R8.4).- Android (v2): o buffer default do mixer é pequeno demais para o pipeline de áudio do Android e produz estouros/crepitação. Em Android usa-se
buffer=1024(ou 2048 se necessário) nomixer.init; no desktop mantém-se o default. A detecção de plataforma vem destorage.is_android()(seção 23), reaproveitando a mesma checagem. Se omixer.initfalhar em qualquer plataforma, o comportamento de degradação graciosa da v1 continua valendo (R8.4, R14.6). - A síntese dos 4 sons acontece uma vez na inicialização. Em aparelho de entrada, gerar ~0,8 s de áudio em Python puro custa na ordem de centenas de milissegundos — aceitável no boot, mas é o motivo de a síntese não poder acontecer durante o jogo.
12. UI (ui.py) — R6, R7.5, R11, R12, R36¶
- Fonte (mudou na v2): a v1 usava
pygame.font.SysFont("couriernew", ...), que não existe no Android — o SDL cairia numa fonte substituta arbitrária ou falharia, quebrando toda a UI. A v2 passa a usar a fonte bitmap própria depixelfont.py(seção 19), gerada por código, garantindo resultado idêntico em todas as plataformas (R7.6) e alinhado ao princípio de "tudo gerado por código" (R7.1). - Sombra dura (offset 3 px, marrom-escuro) e escala inteira permanecem como na v1 (R7.5).
- HUD: pontuação centralizada no topo (R4.2). Telas: PRONTO, PAUSADO (overlay escurecido, R6.3), GAME_OVER (painel com pontuação/recorde, R3.3).
- Controle de mudo na tela (v2, R15.4): ícone de alto-falante desenhado por código no canto superior direito da área lógica, com área de toque generosa (mínimo 44×44 px lógicos) para ser confortável no celular. Estado (com som / mudo) refletido no ícone. Em PAUSADO, o overlay mostra também a dica de mudo por setas do teclado (seção 21.3).
- Tela PRONTO (R6.1, R11, R12, R36), de cima para baixo:
- Título "BLOCKY BEE" (dourado).
- Créditos (
config.CREDITS, "POR DOUGLAS E PEDRO") logo abaixo do título (R11.1). - Instrução de comando ("ESPAÇO / CLIQUE PARA VOAR").
- SE houve ao menos um GAME_OVER nesta execução: "ANTERIOR: N", branco quase puro e menor que o recorde, acima dele com espaçamento maior que o restante da tela para não colar visualmente no recorde (R36.1).
Game.last_scoreguarda esse valor só em memória —Noneaté a primeira volta de GAME_OVER para PRONTO, e nunca escrito emscore.py/storage.py(R36.2, R36.3). - Recorde atual ("RECORDE: N", texto dourado simples, sem caixa/contorno) no rodapé da tela, logo acima do chão (R12.1, R12.2).
- Título da janela (
pygame.display.set_caption) inclui os créditos:"Blocky Bee - por Douglas e Pedro"(R11.2).
13. Loop principal (game.py, main.py)¶
while running:
dt = clock.tick(FPS) # timestep fixo (R9.1, R9.4)
handle_events() # input por estado
state_update() # física, pipes, biomas, score, partículas
state_draw() # fundo → decoração → pipes → chão → bird → partículas → HUD
pygame.display.flip()
Input unificado (R1.1, R10, R15): eventos de teclado, mouse, joystick e toque mapeiam para as mesmas ações abstratas em input.py:
| Ação | Teclado/Mouse | Controle Xbox | Android (toque) |
|---|---|---|---|
flap (voar/reiniciar) |
ESPAÇO, ↑, clique, ENTER | Botão A (JOYBUTTONDOWN, button 0) |
Toque na área de jogo |
pause |
ESC, P | Start (button 7) | BACK durante JOGANDO (R15.2) |
mute |
M, ←/→ em PAUSADO | Y (button 3) | Toque no ícone de mudo |
quit |
— | — | BACK fora de JOGANDO (R15.3) |
src/input.py: classeInputManagerque consomepygame.evente retorna set de ações; os estados só conhecem ações, não dispositivos (R10.4, R15.5).- Init:
pygame.joystick.init()+ inicialização de todos os joysticks presentes (R10.1). - Hotplug: tratar
JOYDEVICEADDED/JOYDEVICEREMOVEDreinicializando o joystick correspondente (R10.2); ausência de controle não afeta o jogo (R10.5). - Índices de botão seguem o mapeamento padrão do controle Xbox no SDL2/Windows; validar no playtest (task 12).
- Detalhes do input Android (toque, BACK) na seção 21.
14. Tratamento de erros¶
| Falha | Comportamento |
|---|---|
| Mixer indisponível | jogo sem som (R8.4, R14.6) |
| highscore.json corrompido, ausente ou com chave/tipo inválido | recorde 0, sobrescreve ao salvar (R4.4) |
| Nenhum controle Xbox conectado | jogo funciona normalmente com teclado/mouse (R10.5) |
| Diretório de save sem permissão de escrita (Android) | save_highscore engole OSError e o jogo segue jogável, sem persistir (R4.5) |
Módulo android indisponível (rodando no desktop) |
storage.py cai no caminho desktop via ImportError (seção 23) |
| App enviado para segundo plano | pausa automática, sem perder progresso nem recorde (R16.1, R16.4) |
15. Estratégia de testes¶
- Unitários (
pytest, 35 testes emtests/na v1, sem abrir janela —conftest.pyforçaSDL_VIDEODRIVER=dummye isola cada teste em umtmp_pathpara nunca ler/gravar ohighscore.jsonreal): física do Bird (gravidade, clamp, impulso, ângulo, hitbox, idle bob), spawn/remoção/movimento/congelamento de textura de pipes, thresholds e timers de bioma, persistência do recorde (arquivo ausente/corrompido/tipo inválido), e oGame(máquina de estados, pontuação única por pipe, colisão, congelamento em pausa). - Novos testes na v2: glifos e métricas de
pixelfont(largura/altura previsíveis, cache, caracteres não suportados), conversão de coordenada de toque → espaço lógico com letterbox (incluindo toque nas barras, que deve ser ignorado),storage.save_dir()nos dois caminhos (com e semANDROID_ARGUMENT, e comImportErrordo móduloandroid), gravação incremental do recorde ao ultrapassar, e as açõesback/focus_lostlevando aos estados corretos. - Manual: checklist de playtest por requisito (
tasks.md), com os itens de Android separados por dependerem de hardware real — ver seção 25.
16. Chão (ground.py) — R7.3¶
Ground.update(speed): acumulaoffset = (offset - speed) % BLOCK, sincronizado com a velocidade do bioma atual (mesma fonte quePipeManager.update).Ground.draw(surface, textures, block_main, block_edge): ladrilha blocos deBLOCK×BLOCKcobrindo a largura da tela a partir deoffset - BLOCK; primeira fileira usablock_edge(grama/borda), demais usamblock_main. Ao contrário dos pipes, não congela textura — usa sempre o bioma atual, já que é uma faixa contínua, não elementos discretos (R2.5 só se aplica a pipes).Ground.rect:Rect(0, SCREEN_H - GROUND_H, SCREEN_W, GROUND_H), usado para colisão pássaro×chão (R3.1).
17. Decoração de fundo (decor.py) — R7.4¶
DecorManagermantém dois acumuladores de scroll (far_scrolled,near_scrolled), incrementados porspeed * 0.3espeed * 0.6a cada frame — nunca resetados com%, só usados módulo o período de cada camada no momento de desenhar.- Tiling:
_tile(surface, scrolled, period, drawer)calcula o índice do primeiro elemento visível (scrolled // period) e a posição inicial (-(scrolled % period)), chamandodrawer(surface, x, idx)para cada slot visível. - Cada elemento é uma forma quadriculada (pilha de retângulos, nunca elipses):
_draw_block_shapedesenha uma lista de(deslocamento_em_unidades, largura_em_unidades)por linha, de cima para baixo.idxsemeiarandom.Random(idx * salt + n)para escolher a variante do elemento — determinístico por slot, então o visual não "pisca" ao rolar. - Por bioma: Overworld → nuvens (
CLOUD_UNIT=14) + colinas (HILL_UNIT=18, apoiadas no chão); Cave → estalactites (triângulos do teto) + veios de minério (clusters de pontos coloridos); Nether → poças de lava (retângulo raso no chão) + pilares (retângulo alto do chão até certa altura).
18. Distribuição e empacotamento — R13¶
- Executável local:
BlockyBee.spec(gerado porpyinstaller --onefile --windowed, depois versionado e usado diretamente) builda comuv run pyinstaller BlockyBee.spec, produzindodist/BlockyBee.exe(Windows) oudist/BlockyBee(Linux/macOS).pyinstalleré dependência de dev apenas (pyproject.toml), não afeta a dependência de runtime do jogo (R9.2 continua valendo: sópygame+ stdlib em tempo de execução) (R13.1). - CI/CD (
.github/workflows/release.yml): gatilhorelease: types: [published]. Job com matrizinclude(os,platform,arch):windows-latest/windows/x64eubuntu-latest/linux/x64—platform/archexplícitos na matriz (não derivados derunner.archem runtime) porque expressões do Actions não têm função de lowercase nativa: actions/checkout@v4.- Instala
uvvia script oficial (install.ps1/install.sh) — evita fixar versão de action de terceiros. uv sync+uv run pyinstaller BlockyBee.spec.- Empacota: Windows →
Compress-ArchivegeraBlockyBee-windows-x64-<tag>.zip; Linux →tar -cjfgeraBlockyBee-linux-x64-<tag>.tar.bz2(nome inclui plataforma, arquitetura egithub.event.release.tag_name). - Publica os assets na própria Release via
softprops/action-gh-release@v2(permissions: contents: writeno workflow) (R13.2). - Na v2 esse mesmo workflow ganha um terceiro job para o APK (seção 24), totalizando três assets por release (R13.3). APK é um build "fat" com múltiplas ABIs (
android.archsnobuildozer.spec:armeabi-v7a, arm64-v8a, x86_64), então o asset é nomeadoBlockyBee-android-universal-<tag>.apkem vez de citar uma arquitetura única.
Parte II — Android (v2)¶
19. Fonte bitmap própria (pixelfont.py) — R7.6¶
Problema. A v1 renderiza todo texto com pygame.font.SysFont("couriernew", size, bold=True). Android não possui Courier New — o SDL substituiria por uma fonte arbitrária (métricas e largura diferentes, quebrando o alinhamento centralizado das telas) ou falharia. Isso torna a UI da v1 inviável no Android.
Solução. Fonte bitmap 5×7 desenhada por código, no mesmo espírito das texturas (R7.1):
- Cada glifo é uma lista de 7 strings de 5 caracteres (
"#"= pixel aceso,"."= vazio), definida como constante no módulo. Conjunto necessário, extraído de todos os textos do jogo:A-Z,0-9,:,/,!,-, espaço. Textos já são todos em maiúsculas na v1, então minúsculas não são necessárias (draw_textaplica.upper()por garantia). render(text, scale, color) -> Surface: monta umaSurfacecomSRCALPHA, acende os pixels de cada glifo como retângulosscale × scale, com 1 coluna de espaçamento entre glifos. Resultado já é pixel-perfeito por construção — dispensa oSysFont+transform.scaleda v1.- Cache:
dict[(text, scale, color)] -> Surface, evitando remontar strings estáticas (título, instruções) a cada frame. O HUD de pontuação muda pouco (no máximo 1× por ponto), então o cache também o cobre bem. ui.draw_textpassa a chamarpixelfont.render, mantendo a assinatura atual e o desenho da sombra dura. As chamadas existentes emui.pyseguem funcionando; o parâmetrobase_sizeda v1 é convertido emscale(fator inteiro de pixel,max(1, base_size // 2)), preservando a hierarquia visual de tamanhos.
Descoberto na implementação (task 20): a fonte bitmap é proporcionalmente mais larga que a SysFont antiga. Cada glifo ocupa 5 + 1 unidades (glifo + espaçamento); numa fonte de sistema como Courier New os caracteres são mais estreitos e há hinting de kerning. Mapear base_size direto para uma escala fixa estourava a tela em 3 dos 9 textos reais do jogo: "BLOCKY BEE" (585 px), "ESPACO / CLIQUE PARA VOAR" (745 px) e "ESPACO / CLIQUE PARA REINICIAR" (716 px) — todos acima dos 480 px disponíveis. Corrigido com um ajuste automático em ui.py:
MAX_TEXT_W = SCREEN_W - 40 # margem de 20px de cada lado
def _fit_scale(text: str, scale: int) -> int:
n = len(text)
while scale > 1 and _text_width(n, scale) > MAX_TEXT_W:
scale -= 1
return scale
draw_text chama _fit_scale antes de renderizar, reduzindo a escala apenas o necessário para caber. Como é calculado por string (não hardcoded por tela), continua correto mesmo que os textos mudem no futuro — não é uma correção pontual para os 3 casos encontrados, é uma garantia geral.
Fallback documentado. Se o custo de desenhar os glifos se mostrar alto, a alternativa é pygame.font.Font(None, size), que usa a fonte embutida no próprio pygame (disponível também no Android, sem depender do sistema). É a rota de menor esforço, porém sem controle pixel-a-pixel do traço — a fonte bitmap é a preferida por consistência visual e por eliminar a dependência do módulo pygame.font.
20. Escala lógica e letterbox (R9.1, R14.3, R14.4)¶
Todo o jogo continua desenhando em coordenadas de 480×720 — nenhuma constante de gameplay muda, e por consequência a calibração de dificuldade da v1 (task 12) permanece válida.
self.screen = pygame.display.set_mode((SCREEN_W, SCREEN_H), pygame.SCALED | pygame.RESIZABLE)
Mesma chamada em desktop e Android, sem nenhum branch por plataforma — no Android, fullscreen = 1 no buildozer.spec (seção 24.2) já faz a Activity ocupar o aparelho inteiro, e orientation = portrait trava a orientação (R14.4).
pygame.SCALEDfaz o SDL renderizar numa surface lógica de 480×720 e escalar para a janela/tela real mantendo a proporção, preenchendo o excedente com uma barra — letterbox (topo/base) ou pillarbox (laterais), conforme a proporção — sem código de escala manual.- Por que a barra é sempre letterbox, nunca pillarbox (R14.3). O canvas lógico do jogo é 480×720 (proporção 2:3 ≈ 0,667 largura/altura). Com
pygame.SCALED(escala =min(janela_w/480, janela_h/720)), a barra sobra no eixo cuja razão NÃO é a menor das duas — ou seja, pillarbox (laterais) só ocorre quando a janela é proporcionalmente mais larga que 2:3. Como a orientação fica travada em retrato (R14.4), a janela do Android nunca fica mais larga que alta; a esmagadora maioria dos celulares é proporcionalmente mais alongada que 2:3 (ex. 20:9 ≈ 0,45), então a barra que sobra é sempre letterbox. Aparelhos hipotéticos mais "quadrados" que 2:3 em retrato (ex. um tablet 4:3) ainda sobrariam algum pillarbox mesmo travados em retrato — aceito conscientemente, dado que a esmagadora maioria dos aparelhos-alvo é mais alongada que a base. - Consequência para o input: coordenadas de mouse já chegam convertidas para o espaço lógico pelo próprio SDL sob
SCALED. Eventos de toque (FINGERDOWN), porém, vêm normalizados em0.0–1.0relativos à janela inteira — incluindo a barra. A conversão está detalhada na seção 21.
20.1 Aproveitamento de tela por aparelho (medido)¶
Aplicando a fórmula acima às resoluções típicas de celular/tablet em retrato:
| Aparelho | Tela | Escala | Área de jogo | Barra | Área útil |
|---|---|---|---|---|---|
| Celular 9:20 | 1080×2400 | 2,25× | 1080×1620 | 390 px topo/base | 68 % |
| Celular 9:16 | 1080×1920 | 2,25× | 1080×1620 | 150 px topo/base | 84 % |
| Tablet 4:3 (retrato) | 1200×1600 | 2,22× | 1067×1600 | 67 px de cada lado | 89 % |
O tablet 4:3 é o exemplo do caso "mais quadrado que 2:3" citado acima — mesmo em retrato, sobra um pillarbox pequeno, aceito conscientemente.
20.2 Incompatibilidade de SCALED com a suíte de testes atual (verificado)¶
Comportamento confirmado experimentalmente neste projeto:
SDL_VIDEODRIVER=dummy + set_mode(SCALED):
1ª chamada -> OK (com aviso "no fast renderer available")
2ª chamada -> pygame.error: failed to create renderer
pygame.SCALED exige criar um renderer SDL, e o driver de vídeo dummy — que o
conftest.py força para rodar headless — só permite um por processo. Como
tests/test_game.py instancia Game() (e portanto chama set_mode) em vários testes
diferentes do mesmo processo, adotar SCALED ingenuamente quebra a suíte.
As variáveis de ambiente SDL_RENDER_DRIVER=software e
SDL_FRAMEBUFFER_ACCELERATION=0 foram testadas e não resolvem.
Solução verificada: derrubar e reinicializar o display antes de cada set_mode, o que
funciona repetidamente:
# fixture autouse em conftest.py (_reset_display)
pygame.display.quit()
pygame.display.init()
21. Entrada Android: toque e BACK (R15)¶
21.1 Toque (celular/tablet)¶
- O SDL sintetiza eventos de mouse a partir do toque por padrão, então
MOUSEBUTTONDOWNjá disparariaflapsem código novo. Ainda assim tratamospygame.FINGERDOWNexplicitamente, para (a) não depender desse comportamento default e (b) suportar toques simultâneos sem ambiguidade. FINGERDOWNtrazevent.x/event.ynormalizados (0.0–1.0) em relação à janela real. Para decidir se o toque caiu no ícone de mudo é preciso converter para coordenadas lógicas, desfazendo o letterbox depygame.SCALED(InputManager._touch_to_logical, seção 20 — usascale.fit_scale, escala min/fit):
win_w, win_h = pygame.display.get_window_size()
scale, off_x, off_y = fit_scale(SCREEN_W, SCREEN_H, win_w, win_h) # escala = MIN dos dois eixos
lx = (event.x * win_w - off_x) / scale
ly = (event.y * win_h - off_y) / scale # -> espaço lógico 480x720
- Toque fora da área lógica (na barra de letterbox) é ignorado — não dispara
flapnemmute; toque no ícone de mudo alterna mudo; qualquer outro toque na área de jogo emiteflap(R15.1, R15.4).
Revogado na v3 (R34, seção 32.8). O descarte por área descrito acima vale enquanto o espaço fora do jogo for barra preta de letterbox. Na v3 esse espaço vira faixa decorativa desenhada, e o toque sobre ela passa a valer como toque na área jogável — o único recorte que sobra é contra o canvas inteiro.
Descoberto na implementação (task 23): o MOUSEBUTTONDOWN sintetizado pelo toque real precisa do MESMO hit-test do ícone. Se MOUSEBUTTONDOWN continuasse mapeando para flap incondicionalmente (como na v1), tocar no ícone de mudo no Android dispararia os dois eventos — FINGERDOWN (mudo, correto) e o MOUSEBUTTONDOWN sintético (flap, incorreto) — no mesmo frame. Resolvido com um _handle_tap(actions, lx, ly) único, chamado por ambos os handlers: com pygame.SCALED em uso, MOUSEBUTTONDOWN passa event.pos direto (já convertido para o espaço lógico pelo próprio SDL); FINGERDOWN passa pela conversão manual acima. ui.MUTE_ICON_RECT fica em ui.py, junto do draw_mute_icon() que o desenha, e input.py importa essa geometria para o hit-test — mantendo desenho e posição do botão como uma única fonte de verdade.
21.2 Botão BACK (R15.2, R15.3)¶
O SDL mapeia o BACK do Android para a tecla pygame.K_AC_BACK. O InputManager a traduz em uma ação back, e o Game decide pelo estado:
| Estado | BACK faz |
|---|---|
| JOGANDO | pausa (vira ação pause) |
| PRONTO / PAUSADO / GAME_OVER | encerra o jogo |
Esse desvio fica no Game (que conhece o estado), não no InputManager — mantendo a separação da v1 em que o input não conhece estados.
21.3 Mapeamentos adicionais de teclado (R15.4, R15.5)¶
K_RETURN/K_KP_ENTER mapeiam para flap e as setas ←/→ alternam mudo no overlay de PAUSADO — mapeamentos adicionais aos de teclado/mouse/gamepad já cobertos (R10, R15.5), inofensivos em qualquer plataforma (Enter já é um atalho razoável também no desktop).
Bug real encontrado ao revisar a alcançabilidade. Um perfil de entrada só com teclas de seta, Enter e BACK — sem tecla ESC/P e sem botão Start de gamepad — conseguia pausar (BACK durante JOGANDO, seção 21.2) mas não tinha como despausar: _flap_action() não tratava o estado PAUSADO (era no-op), e BACK em PAUSADO encerra o jogo em vez de alternar. O único caminho restante era sair. O mesmo gap afetava quem só usa toque no celular (tocar a tela tampouco despausava). Corrigido fazendo _flap_action() também transicionar PAUSADO → JOGANDO (sem chamar bird.flap(), para não dar um pulo indesejado ao retomar) — reaproveitando a mesma ação primária (RETURN/toque) já usada para iniciar e reiniciar, em vez de inventar um mecanismo novo.
22. Ciclo de vida do app (R16.1, R16.2)¶
O pygame 2 expõe os eventos de ciclo de vida do SDL:
elif event.type in (pygame.APP_WILLENTERBACKGROUND, pygame.APP_DIDENTERBACKGROUND):
actions.add(ACTION_FOCUS_LOST)
Gametratafocus_losttransicionando JOGANDO → PAUSADO (R16.1). Nos outros estados é ignorado.- Ao voltar (
APP_DIDENTERFOREGROUND) o jogo permanece em PAUSADO — nenhuma retomada automática, para o jogador não perder a partida por causa de uma volta inesperada (R16.2). - Como fallback em plataformas que não emitem os eventos de app,
pygame.WINDOWFOCUSLOSTrecebe o mesmo tratamento (útil também no desktop: alt-tab pausa o jogo). - O recorde já está salvo nesse ponto pela gravação incremental (seção 8), então um encerramento pelo sistema não perde nada (R16.4).
23. Armazenamento por plataforma (storage.py) — R4.5¶
A v1 grava highscore.json via caminho relativo, resolvido a partir do diretório de trabalho. No Android o diretório de trabalho não é gravável, e a escrita falharia silenciosamente (a v1 já engole OSError), fazendo o recorde nunca persistir.
def is_android() -> bool:
return "ANDROID_ARGUMENT" in os.environ # definido pelo python-for-android
def is_frozen() -> bool:
return getattr(sys, "frozen", False) # definido pelo PyInstaller no executavel empacotado
def save_dir() -> Path:
if is_android():
try:
from android.storage import app_storage_path # fornecido pelo p4a
return Path(app_storage_path())
except ImportError:
return Path(os.environ.get("ANDROID_PRIVATE", "."))
if is_frozen():
return Path(sys.executable).resolve().parent # pasta do .exe/binario empacotado
return Path(__file__).resolve().parent.parent # raiz do projeto, rodando de fonte
- Detecção por variável de ambiente (
ANDROID_ARGUMENT), definida pelo p4a — não exige importar nada no desktop. - Caminho Android:
app_storage_path()do móduloandroid(embutido pelo p4a) devolve o diretório privado do app, gravável e preservado entre execuções.ANDROID_PRIVATEé o fallback. - Caminho desktop rodando de fonte: raiz do projeto, resolvida a partir do arquivo do módulo — mais robusto que o diretório de trabalho e continua compatível com a suíte de testes, que injeta um
tmp_pathexplícito. - Caminho desktop empacotado (
BlockyBee.spec/PyInstaller, R13): a pasta do executável, resolvida a partir desys.executable. score.load_highscore()/save_highscore()passam a usarstorage.save_dir() / "highscore.json"como default, mantendo o parâmetropathopcional que os testes já usam.
Bug pós-lançamento (v1.0.0): recorde não persistia no executável empacotado do Windows. BlockyBee.spec gera um executável onefile (EXE(pyz, a.scripts, a.binaries, a.datas, ...) em uma única chamada). Nesse modo, o PyInstaller extrai o conteúdo empacotado para um diretório temporário (sys._MEIPASS) a cada execução e é esse diretório que __file__ resolve dentro do .exe — não a pasta onde o executável está. Como o diretório é apagado quando o processo termina, highscore.json nunca aparecia ao lado do .exe e o recorde se perdia a cada fechamento. O mesmo problema afeta o build Linux (mesmo .spec), só não havia sido reportado ainda. Corrigido detectando o modo empacotado via sys.frozen (atributo que o PyInstaller injeta em tempo de execução) e usando Path(sys.executable).resolve().parent nesse caso — a pasta real do executável, preservada entre execuções e coerente com a distribuição em zip/tar portátil (R13.2), que não instala em local somente-leitura como Program Files. tests/test_storage.py::test_save_dir_frozen_desktop_uses_executable_dir cobre o caso simulando sys.frozen/sys.executable via monkeypatch. Validado também manualmente: build real via uv run pyinstaller BlockyBee.spec, simulação do modo frozen apontando para dist/BlockyBee.exe e confirmação de que highscore.json é criado e lido corretamente ao lado do executável.
Descoberto na implementação (task 22): o default não pode ser um valor de parâmetro fixo. def load_highscore(path: Path = storage.save_dir() / "highscore.json") calcularia o caminho uma única vez, na importação do módulo — clássica armadilha de default mutável/calculado em Python. Isso congelaria o resultado de storage.save_dir() para sempre no valor visto no import (impossibilitando reagir a mudança de plataforma em runtime, e tornando o comportamento impossível de isolar via monkeypatch nos testes). Corrigido resolvendo dentro do corpo da função, com None como sentinela:
def _default_path() -> Path:
return storage.save_dir() / "highscore.json"
def load_highscore(path: Path | None = None) -> int:
path = path if path is not None else _default_path()
...
Consequência para o isolamento dos testes. A fixture _isolate_cwd do conftest.py (task 13) já isolava highscore.json via monkeypatch.chdir(tmp_path), porque a v1 resolvia o caminho padrão relativo ao cwd. Como storage.save_dir() no desktop agora resolve a partir de __file__ (não do cwd), o chdir sozinho deixou de bastar — a fixture precisou ganhar monkeypatch.setattr("src.storage.save_dir", lambda: tmp_path). Isso, por sua vez, tornou storage.save_dir() impossível de testar de verdade em tests/test_storage.py (a fixture autouse mascarava a implementação real para todo teste); resolvido com monkeypatch.undo() no início dos testes que precisam da implementação real, revertendo a proteção só ali.
24. Empacotamento Android e CI do APK (R17)¶
24.1 Cadeia de build¶
Buildozer → python-for-android (p4a) → bootstrap SDL2 → APK. É a rota padrão para Python em Android e a única que preserva o código pygame existente (as alternativas exigiriam reescrever a renderização em Kivy ou migrar para WASM, o que não gera APK).
Risco conhecido, decidido conscientemente. A receita de
pygame-cepara o python-for-android não está mergeada no p4a — vive num pull request aberto (kivy/python-for-android#2971, sem merge desde 2024). Portanto o projeto carrega sua própria receita local emp4a-recipes/pygame-ce/__init__.py, apontada porp4a.local_recipesnobuildozer.spec. Consequências: (a) a receita é código nosso a manter; (b) atualizações depygame-cepodem exigir ajuste na receita; (c) a task de build (28) deve tratar "a receita não compila" como resultado possível, e nesse caso a alternativa é fixar a versão depygame-ceconhecida como funcional. Esse é o ponto de maior incerteza da v2 e deve ser atacado cedo.Atualização (task 28): o risco se confirmou parcialmente, e foi resolvido. O Docker ficou acessível neste ambiente de desenvolvimento (algo que a seção 25 originalmente dava como indisponível) e o build real foi executado até
BUILD SUCCESSFUL, gerando um APK de fato. A receita copiada do PR upstream tinha dois bugs reais, só visíveis ao tentar compilar de verdade: 1. Faltava'cython'emdepends—setup.py build_extfalha com "You need cython" sem isso (outras receitas do p4a que compilam.pyx, comonumpy/av, declaram essa dependência; a cópia do PR não). 2.sdl_image_includesapontava para a raiz dejni/SDL2_image, mas a versão dosdl2_imagerecipe do p4a (2.8.0) move o header público parajni/SDL2_image/include/SDL_image.h— diferente doSDL2_ttf, que mantémSDL_ttf.hna raiz (mesmo padrão, layout diferente).Nenhum dos dois exigiu trocar a versão do
pygame-ce(2.5.7 seguiu funcionando) — só corrigir a receita em si. Também apareceu um bug não relacionado ao pygame-ce, no própriojpegrecipe built-in do p4a: seuCMakeLists.txt(libjpeg-turbo 2.0.1) exigecmake_minimum_required< 3.5, incompatível com o CMake 4.2.3 do containerkivy/buildozeratual — resolvido com uma receita local própria (p4a-recipes/jpeg/__init__.py) que passa-DCMAKE_POLICY_VERSION_MINIMUM=3.5. Isso ilustra um risco mais amplo que a seção 24.1 original não cobria: o ambiente de build (versão do CMake, do compilador, do próprio p4a) pode driftar independentemente do código do projeto, já que a imagemkivy/buildozernão é pinada por hash — um rebuild futuro pode reintroduzir problemas parecidos mesmo sem nenhuma mudança no repositório.
24.2 buildozer.spec — pontos que importam¶
requirements = python3,pygame-ce,android
orientation = portrait # trava retrato, elimina pillarbox (R14.3/R14.4)
fullscreen = 1
p4a.local_recipes = ./p4a-recipes
p4a.bootstrap = sdl2
android.api = 34 # target moderno
android.minapi = 21 # Android 5.0+ (R14.1)
android.ndk_api = 21
android.archs = armeabi-v7a, arm64-v8a, x86_64 # (R14.2)
android.allow_backup = True
androidentra emrequirementspor causa doandroid.storageusado na seção 23.orientation = portrait: trava a activity em retrato — o jogo nunca roda em paisagem no Android, mesmo se o aparelho for girado. É o que garante, combinado compygame.SCALED(seção 20), que a barra de letterbox sobre sempre no topo/base e nunca nas laterais (pillarbox).- Assinatura: build
debug(buildozer android debug), assinado com a chave de debug do Android SDK. Instala direto com "fontes desconhecidas" habilitado; não serve para Play Store (fora de escopo, R17.1).
24.3 Job de CI do APK (R17.2)¶
Acrescentado ao release.yml existente, como job independente dos de desktop (matriz Windows/Linux da seção 18):
runs-on: ubuntu-latest— Buildozer exige Linux.- Build dentro de container Docker (
kivy/buildozerou imagem própria) para ter Android SDK/NDK reprodutíveis, evitando instalar a toolchain no runner. - Cache de
~/.buildozere.buildozerviaactions/cache— sem cache, o primeiro build baixa SDK+NDK e compila CPython+SDL2+pygame-ce, tipicamente 30–60 min. buildozer android debug→ APK embin/.- Renomear para
BlockyBee-android-universal-<tag>.apk(nome inclui plataforma e "universal" por conter múltiplas ABIs) e publicar sem compressão viasoftprops/action-gh-release@v2— o.apkjá é um zip; comprimir de novo só atrapalharia a instalação direta (R17.2).
O job é independente para que uma falha no build Android (o passo mais frágil, ver risco em 24.1) não impeça a publicação dos binários de desktop.
Correção necessária ao item 3, descoberta na task 28 (build local, não no CI ainda). O cache real do Android SDK/NDK do buildozer vive em
$HOME/.buildozerdentro do container (/home/user/.buildozerna imagemkivy/buildozer), não em.buildozerdo workspace — esse último só guarda os artefatos de build por recipe/arch. Rodardocker run --rm -v "$WORKSPACE:/home/user/hostcwd" kivy/buildozer ...sem também montar/home/user/.buildozerfaz o SDK/NDK (~1-2 GB) serem baixados de novo em tododocker run, mesmo comactions/cacheno caminho errado. Pior: o marcador "SDK já instalado" (android:sdk_installationemstate.db, dentro de.buildozerdo workspace, que É cacheado) fica dessincronizado do container efêmero — em builds subsequentes o buildozer acredita que os pacotes SDK (platforms;android-34etc.) já estão instalados e pula a etapa, resultando emAvailable Android APIs are ()e falha. Para o job de CI (task 29), isso significa: cachear/montar também um diretório persistente para/home/user/.buildozerdo container (não só.buildozerdo workspace), do mesmo jeito que a task 28 resolveu localmente com um segundo bind mount.
25. Limites de verificação nesta versão¶
Registrado explicitamente porque afeta como as tasks devem ser aceitas:
- Não há aparelho Android nem emulador neste ambiente de desenvolvimento. Logo, os itens que dependem de Android real — instalar o APK, jogar por toque, medir FPS em aparelho de entrada, confirmar persistência do recorde no storage do app — precisam de validação manual pelo dono do projeto.
- Atualização (task 28): o Docker local, dado como inacessível na v1 (ao tentar validar o workflow com
act), ficou disponível neste ambiente numa sessão posterior. Isso permitiu rodar o build real (docker run kivy/buildozer android debug) ponta a ponta atéBUILD SUCCESSFUL, algo que a v1 não conseguia verificar — ver seção 24.1/24.2/24.3 para os bugs reais encontrados e corrigidos nesse processo. A disponibilidade do Docker pode variar entre sessões/ambientes; não assumir que builds futuros terão o mesmo acesso sem verificar (docker ps) primeiro. - Atualização (task 72): Docker disponível de novo numa sessão posterior, o build da v3 rodou até
BUILD SUCCESSFUL(bin/blockybee-0.3.0-armeabi-v7a_arm64-v8a-debug.apk), mas um aparelho/emulador Android real continua ausente — o.apkgerado nunca chegou a ser instalado nem tocado. Um bug real da mesma família da task 28 apareceu de novo, desta vez no lado do SDK em vez dohostpython3:.buildozer/state.db(cache local, gitignored) guardava o SDK como já instalado de uma sessão anterior, mas o cache montado ($HOME/.buildozer) nasceu vazio nesta sessão — obuildozerconfiou no marcador em vez do conteúdo real, pulou a instalação dos pacotes do SDK e falhou comAvailable Android APIs are (). Fix: apagar ostate.dbstale para forçar reinstalação. Ver task 72 emtasks.mdpara o relato completo. - O que pode ser verificado automaticamente aqui: fonte bitmap (comparação de superfícies renderizadas), conversão de coordenadas de toque para espaço lógico (função pura, testável), resolução do diretório de save por plataforma (com
ANDROID_ARGUMENTmonkeypatched), transições de estado por açõesback/focus_lost(eventos sintéticos), e o jogo inteiro no desktop comSCALED(incluindo redimensionamento de janela). - O checklist manual em
tasks.mdmarca claramente qual item é de qual categoria. Nenhum item dependente de hardware deve ser marcado como concluído sem teste real.
Parte III — Qualidade (aumento de escopo da v2)¶
26. Conformidade com Ruff (R18)¶
Config. [tool.ruff] em pyproject.toml, sem arquivo ruff.toml separado (mantém o padrão de configuração centralizada já usado para pytest/dependências):
[tool.ruff]
line-length = 110
target-version = "py310" # alinhado a requires-python (R9.2)
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
ignore = []
[tool.ruff.lint.isort]
known-first-party = ["src"]
line-length = 110: a base de código atual tem linhas de até 122 caracteres (pipes.py); 110 é folgado o bastante para não forçar quebras artificiais em toda expressão de configuração/design (constantes de bioma, tuplas de cor), mas ainda mais apertado que o "sem limite" de fato — a auditoria inicial (task de conformidade) deve rever as poucas linhas acima de 110 caso a caso, não simplesmente subir o limite para acomodá-las.- Conjunto de regras:
E/W(pycodestyle),F(pyflakes — imports não usados, variáveis não usadas, o núcleo do lint),I(isort — ordenação de imports, já relevante porquesrc/*.pyimporta consistentemente comfrom src import x),UP(pyupgrade — sintaxe moderna compatível com orequires-python = ">=3.10"do projeto),B(bugbear — armadilhas comuns tipo mutable default argument, relevante aqui porque a v2 já documentou um bug real dessa categoria emscore.py, seção 22),SIM(simplificações óbvias),RUF(regras específicas do próprio ruff). Não incluiD(docstrings) nemANN(obrigatoriedade de type hints) — o projeto não usa docstrings de módulo/função de forma sistemática nem type hints em 100% das assinaturas, e exigir isso agora seria um escopo muito maior que "conformidade com ruff"; pode ser revisitado numa versão futura se o time decidir adotar esse padrão. - Formatação:
uv run ruff format .(equivalente ablack, embutido no ruff — não precisa de dependência separada) aplicada uma vez no repositório inteiro como parte da task de conformidade; depois disso,ruff format --checkno CI garante que não regride.
Escopo da varredura. Todo .py versionado: main.py, src/**/*.py, tests/**/*.py, scripts/**/*.py, p4a-recipes/**/__init__.py, conftest.py. As receitas em p4a-recipes/ são código nosso (não vendored de terceiros — a v2 já as adaptou/corrigiu, seção 24.1), então entram na varredura como qualquer outro módulo do projeto.
CI. Não existe hoje um workflow que rode em push/PR (release.yml só dispara em publicação de Release) — esta extensão de escopo adiciona .github/workflows/ci.yml, on: [push, pull_request], runs-on: ubuntu-latest, com uv sync, uv run ruff check ., uv run ruff format --check . e SDL_VIDEODRIVER=dummy uv run pytest como steps do mesmo job (falha rápida: lint antes dos testes). Isso é uma lacuna que esta extensão fecha incidentalmente — sem esse workflow, uma regressão de lint (ou de teste) só seria percebida manualmente ou na hora de cortar uma release.
Correção de violações. Regra do projeto (R18.6): consertar o código, não silenciar. Supressões (# noqa: CODE) só quando a regra genuinamente não se aplica ao caso (ex.: um import não usado propositalmente para efeito colateral), sempre com o código específico da regra (nunca # noqa nu) e um comentário de uma linha explicando o motivo.
27. Calibração de tamanho de fonte (R19)¶
Problema real, não cosmético. ui._fit_scale() (v2, seção 19) só protege contra estouro horizontal — reduz a escala até a largura do texto caber em MAX_TEXT_W. Não existe proteção equivalente na vertical: os deslocamentos entre linhas de uma mesma tela são deltas fixos em pixels, calculados à mão para os base_size originais (ex. draw_ready_screen: título em SCREEN_H // 3, créditos em + 28, instrução em + 70; draw_game_over_screen: - 60, + 0, + 30, + 70). A altura real de uma linha renderizada é pixelfont.GLYPH_H * scale (7 × scale) mais a sombra (offset de 3 px) — se scale for maior do que o assumido quando esses deltas foram escolhidos, duas linhas vizinhas colidem. É exatamente o sintoma relatado: textos grandes demais se sobrepondo em várias telas.
Abordagem. Duas mudanças complementares em ui.py, sem tocar em pixelfont.py:
- Layout vertical por empilhamento, não por offset fixo. Uma função
_stack(surface, center_x, top_y, lines)que recebe uma lista de(text, base_size, color)na ordem em que aparecem na tela, calcula a escala de cada linha (via_fit_scale, já existente) e posiciona cada uma logo abaixo da anterior, com uma margem fixa pequena (ex. 8 px) entre linhas — a posição de uma linha passa a depender da altura real (GLYPH_H * scale) da linha anterior, não de uma constante escolhida a olho. Isso resolve o critério R19.3 de uma vez para as quatro telas, e é a mudança estrutural principal desta extensão de escopo. - Revisão dos
base_sizepor papel. Com o empilhamento cuidando do espaçamento vertical, o que resta ajustar é o tamanho absoluto de cada papel de texto para a resolução lógica de 480×720. Os valores atuais (title=18,HUD=20) resultam emscale = base_size // 2→ 9 e 10, ou seja glifos de 45–50 px de altura num canvas de 720 px — proporcionalmente grandes para uma tela que também precisa caber título + subtítulo + instrução + recorde sem se espremer. A task de implementação deve testar visualmente uma faixa reduzida (ex. títuloscale5–6 em vez de 9, textos secundáriosscale2–3) e registrar os valores finais escolhidos — o "tamanho ideal" pedido não é um número que dá para derivar analiticamente, é uma calibração visual como a task 12 (curva de dificuldade) já foi para gameplay.
Verificação automatizada (R19.5). Como toda a UI é desenhada em coordenadas lógicas fixas (480×720, independente do aparelho — a escala física fica inteiramente a cargo de pygame.SCALED, seção 20), a ausência de sobreposição é uma propriedade determinística do código, testável sem precisar de tela real:
# tests/test_ui_layout.py (novo)
def _rects_for_screen(draw_fn, *args) -> list[pygame.Rect]:
"""Instrumenta draw_text para capturar os Rects em vez de (só) desenhar."""
...
def test_ready_screen_texts_do_not_overlap():
rects = _rects_for_screen(ui.draw_ready_screen, highscore=999999)
for a, b in itertools.combinations(rects, 2):
assert not a.colliderect(b)
for r in rects:
assert 20 <= r.left and r.right <= SCREEN_W - 20
Repetido para as quatro telas (PRONTO, HUD+nada mais por enquanto — só um texto, mas fica como regressão —, PAUSADO, GAME_OVER), incluindo o caso de recorde com muitos dígitos (RECORDE: 999999) para não regredir se o score crescer além do testado até aqui. O ícone de mudo (ui.MUTE_ICON_RECT, constante) entra na mesma checagem de colisão nas telas onde é desenhado (celular, R15.4) — é um retângulo fixo, não precisa de instrumentação extra.
Por que não mexer em pixelfont.py. O glifo 5×7 em si não é o problema — é proporcional e legível; o que precisa de ajuste é onde e em que escala cada texto é colocado. Manter a mudança inteira em ui.py preserva o cache de pixelfont.render (v2, seção 19) e não arrisca reabrir a receita de build Android (a fonte já roda em produção real desde a task 28 da v2).
28. Conformidade com ty (R20)¶
Por que ty, além de ruff. ruff (seção 26) cobre estilo e armadilhas sintáticas, mas não checa se os tipos batem — e a v2 já teve um bug real dessa categoria escapar para produção (persistência do recorde, seção 23). ty é o checador de tipos estático do mesmo time do ruff/uv (Astral), então entra com a mesma filosofia de configuração centralizada em pyproject.toml e sem dependência extra de toolchain.
Config. [tool.ty.environment]/[tool.ty.src] em pyproject.toml, mesmo padrão de configuração centralizada usado para ruff:
[tool.ty.environment]
python-version = "3.10" # alinhado a requires-python (R9.2), mesmo raciocinio do target-version do ruff
[tool.ty.src]
exclude = ["p4a-recipes", "specs", ".buildozer", "build", "dist"]
python-version = "3.10": alinhado aorequires-pythondo projeto, mesmo raciocínio dotarget-versiondo ruff (seção 26).exclude:p4a-recipes/sai do escopo porque essas receitas importamshepythonforandroid.*— pacotes que só existem dentro da imagem Docker do buildozer (R17, seção 24.1), nunca no.venvde desenvolvimento local; checá-las localmente só produziriaunresolved-importpermanente e não-acionável, diferente deruff(que enxerga só sintaxe/estilo e não precisa resolver imports de verdade).specs/,.buildozer/,build/,dist/saem pelo mesmo motivo despecs/no ruff (não é código do jogo) mais artefatos de build que não são versionados.
Escopo da varredura. main.py, src/**/*.py, tests/**/*.py, scripts/**/*.py, conftest.py — o mesmo escopo do ruff (seção 26) menos p4a-recipes/.
CI. Novo step em .github/workflows/ci.yml, uv run ty check ., entre ruff format --check e pytest (mesma lógica de falha rápida: tipos antes de rodar a suíte).
Supressões pontuais (R20.4). Dois padrões legítimos não são erro de tipo de verdade, só uma limitação do que é estaticamente verificável, e usam # ty: ignore[regra] com comentário curto (mesma disciplina do # noqa: CODE do ruff, R18.6):
src/storage.py:from android.storage import app_storage_pathdentro dotry/except ImportError(seção 23) — módulo só existe em runtime python-for-android, nunca no venv de dev.# ty: ignore[unresolved-import].tests/test_storage.py: o fake do móduloandroid.storage(test_save_dir_android_uses_app_storage_path) atribui atributos dinamicamente a uma instância detypes.ModuleTypepara simular o módulo injetado pelo p4a — válido em runtime (módulos aceitam atributo arbitrário), mas não declarado no stub deModuleType.# ty: ignore[unresolved-attribute].
Violações reais encontradas na auditoria inicial (3, todas corrigidas no código — nenhuma suprimida):
src/biome.py(_lerp_color) esrc/particles.py(ParticleSystem.burst): ambas construíam uma cor RGB a partir de uma expressão de tamanho variável (genexpr num caso, slice depygame.Colorno outro) e atribuíam a umtuple[int, int, int]—tyinferetuple[int, ...]para as duas formas, então o tamanho fixo nunca é garantido estaticamente (só por convenção do chamador). Corrigido desempacotando os três componentes em variáveis nomeadas e retornando/atribuindo um literal de tupla de 3 elementos, quetyjá infere com o tamanho certo — sem mudar o comportamento em runtime.src/input.py(InputManager):dict[int, pygame.joystick.Joystick]usavaJoystickcomo anotação de tipo, mas o próprio stub do pygame-ce documenta queJoystické, na implementação atual, uma função-fábrica que devolveJoystickType(não uma classe) — "in the future, when the C implementation is fixed to add__init__/__new__to Joystick and it's exported directly, the typestubs here must be updated too". Corrigido usandopygame.joystick.JoystickType, o tipo de verdade da instância. Aproveitado para remover a chamada redundantejoystick.init()logo apósJoystick(device_index)(o stub marcaJoystickType.initcomo@deprecated("since 2.0.0. Multiple initializations are not supported anymore")— a construção já inicializa o joystick).
Suíte completa (70 testes) e ruff check/ruff format --check permanecem verdes após todas as correções.
Parte IV — Ícone do aplicativo (aumento de escopo da v2)¶
29. Ícone do app: geração por código e integração por plataforma (R21)¶
Problema. O jogo nunca definiu um ícone próprio. Na janela do desktop (pygame.display.set_mode, R9.1) o pygame usa seu ícone padrão; o executável PyInstaller (BlockyBee.spec, R13.1) não passava icon=, então o .exe herdava o ícone genérico do bootloader; o buildozer.spec (R17.1) não declarava icon.filename nem as chaves de ícone adaptativo, então o APK usava o ícone padrão do Android (o "robozinho" verde genérico do template do buildozer). Nada disso identificava o jogo visualmente antes de abri-lo.
Princípio herdado (R7.1). Como todo o resto da apresentação visual, o ícone é gerado por código, reaproveitando textures.make_bee() — nenhum arquivo de imagem externo entra no repositório manualmente. scripts/generate_app_icon.py segue o mesmo padrão já usado por outros scripts de geração de assets do projeto: um script standalone que importa src/textures.py/src/pixelfont.py, desenha em superfícies pygame.Surface e salva PNGs em assets/. A diferença desta task é que, além de PNG, o ícone do Windows precisa de um arquivo .ico multi-resolução.
29.1 scripts/generate_app_icon.py¶
Gera cinco arquivos em assets/, todos a partir da mesma abelha-fonte (textures.make_bee(0), redesenhada em alta resolução por reamostragem pygame.transform.scale sem suavização — igual a textures.py, R7.1 exige pixel-art nítida, não um blur):
| Arquivo | Tamanho | Uso | Camada |
|---|---|---|---|
app_icon_512.png |
512×512 | Ícone de janela do desktop (pygame.display.set_icon, R21.2) e fonte para o .ico |
abelha + fundo (céu/grama), composto |
app_icon.ico |
16/32/48/64/128/256 px, um único arquivo | Ícone do .exe no PyInstaller (BlockyBee.spec, R21.3) |
mesma composição de app_icon_512.png, reamostrada por tamanho |
android_icon_legacy.png |
512×512 | icon.filename do buildozer.spec — Android < 8.0/API 26 sem suporte a ícone adaptativo (R21.6) |
abelha + fundo, com margem de ~10% (launchers antigos aplicam sua própria máscara/sombra por cima, sem zona segura formalizada) |
android_icon_foreground.png |
432×432, fundo transparente | icon.adaptive_foreground.filename (R21.4) |
só a abelha, escalada para caber nos 66 dp centrais de um canvas de 108 dp (≈61%, ou ≤264 px de lado dentro do canvas de 432 px) — a zona segura de máscara (R21.5) |
android_icon_background.png |
432×432, opaco | icon.adaptive_background.filename (R21.4) |
gradiente de céu liso (mesmas cores de biome.py, bioma Overworld), sem a abelha — camada de fundo do ícone adaptativo não precisa de zona segura, só não deve ter detalhe importante perto da borda |
# scripts/generate_app_icon.py (esqueleto)
FOREGROUND_CANVAS = 432
SAFE_ZONE_FRACTION = 66 / 108 # zona segura do icone adaptativo Android (R21.5)
def make_foreground() -> pygame.Surface:
surf = pygame.Surface((FOREGROUND_CANVAS, FOREGROUND_CANVAS), pygame.SRCALPHA)
bee = textures.make_bee(0)
max_side = int(FOREGROUND_CANVAS * SAFE_ZONE_FRACTION)
bee = _scale_nearest_fit(bee, max_side) # nearest-neighbor, preserva proporcao, cabe em max_side
surf.blit(bee, bee.get_rect(center=(FOREGROUND_CANVAS // 2, FOREGROUND_CANVAS // 2)))
return surf
_scale_nearest_fit: mesma técnica detextures.py(escala inteira/vizinho-mais-próximo) aplicada ao maior lado da abelha até caber emmax_side, mantendo a proporção original do sprite — evita esticar a abelha de forma desproporcional (o pedido original do dono do projeto, "proporções ajustadas").- Composição do ícone com fundo (
app_icon_512.png,android_icon_legacy.png): céu com o mesmo gradiente do Overworld (biome.BIOMES[0].sky_top/sky_bottom) e uma faixa de grama/terra na base (reaproveitandotextures.make_block("grass_side")/"dirt"), com a abelha centralizada e ocupando a maior parte do quadro — visualmente consistente com a cena real do jogo.
29.2 Construção do .ico sem depender de Pillow¶
O projeto não tem Pillow como dependência (R9.2 restringe o runtime a pygame-ce + stdlib; scripts de geração de assets seguem a mesma disciplina para não introduzir uma dependência de build só para isto). O formato ICO moderno (desde o Windows Vista) aceita cada entrada como um PNG completo em vez de um bitmap BITMAPINFOHEADER cru — é o que torna viável montar o .ico só com pygame.image.save (gera os PNGs) e o módulo struct da stdlib (monta o container):
import struct
ICO_SIZES = [16, 32, 48, 64, 128, 256]
def build_ico(source: pygame.Surface, out_path: Path) -> None:
entries = []
for size in ICO_SIZES:
scaled = pygame.transform.smoothscale(source, (size, size))
buf = io.BytesIO()
pygame.image.save(scaled, buf, "app_icon.png") # forca o encoder PNG do pygame via extensao
png_bytes = buf.getvalue()
entries.append((size, png_bytes))
header = struct.pack("<HHH", 0, 1, len(entries)) # ICONDIR: reservado, tipo=1 (icone), contagem
offset = len(header) + len(entries) * 16 # cada ICONDIRENTRY tem 16 bytes
dir_entries = b""
image_data = b""
for size, png_bytes in entries:
wh = 0 if size == 256 else size # 0 significa 256 no formato ICO
dir_entries += struct.pack(
"<BBBBHHII", wh, wh, 0, 0, 1, 32, len(png_bytes), offset
)
image_data += png_bytes
offset += len(png_bytes)
out_path.write_bytes(header + dir_entries + image_data)
- Diferente da composição do ícone em si (que usa escala nearest-neighbor para preservar o estilo pixel-art), o redimensionamento para o
.icousasmoothscale: em tamanhos pequenos (16/32 px) o nearest-neighbor de um sprite originalmente desenhado a 512 px produziria ruído ilegível — o mesmo trade-off que qualquer ícone de app enfrenta entre "pixel-art fiel" e "legível em 16 px". A composição de origem (app_icon_512.png) permanece nearest-neighbor/pixel-perfeita; só a redução de escala para os tamanhos pequenos do.icousa suavização. - Validação do
.icogerado: reabrir cada entrada compygame.image.loada partir dos bytes extraídos (round-trip) e checar as dimensões — suficiente para garantir que o container está bem formado, sem precisar de uma lib externa de leitura de.ico.
29.3 Integração no desktop (R21.2, R21.3)¶
- Ícone da janela (
src/game.py,Game.__init__): logo antes doset_mode, carregaapp_icon_512.pngvia um novo helpersrc/assets.py::asset_path()e chamapygame.display.set_icon(pygame.image.load(...)). Envolvido emcontextlib.suppress(OSError, pygame.error)que apenas segue sem ícone customizado — mesma disciplina de degradação graciosa já usada para áudio (R8.4, design seção 14): um ícone ausente/corrompido nunca deve impedir o jogo de abrir. src/assets.py::asset_path(filename): resolve o caminho de um asset lido em runtime (hoje, só o ícone da janela — texturas/sons continuam 100% proceduais, sem arquivo), considerando onde o arquivo foi bundlado:
def asset_path(filename: str) -> Path:
if storage.is_frozen():
# PyInstaller onefile extrai os dados empacotados (via `datas=` no .spec)
# para sys._MEIPASS a cada execucao — ao contrario de storage.save_dir()
# (secao 23), aqui o diretorio temporario e o lugar CERTO para ler um
# asset builtin/somente-leitura, nao para gravar algo que precisa
# sobreviver ao fechamento do processo.
base = Path(getattr(sys, "_MEIPASS", "."))
else:
base = Path(__file__).resolve().parent.parent # raiz do projeto (fonte ou apk do p4a)
return base / "assets" / filename
Distinção importante em relação a storage.save_dir() (seção 23): lá, sys._MEIPASS é explicitamente evitado porque é o diretório certo pra ler, mas errado pra persistir (é apagado ao fechar o processo) — o bug da task 33 foi justamente usar __file__/implicitamente _MEIPASS para decidir onde gravar. Aqui o caso é o oposto: o ícone é um recurso somente-leitura empacotado junto do executável, e _MEIPASS é exatamente onde o PyInstaller o extrai — usar sys.executable.parent aqui exigiria copiar o PNG manualmente para perto do .exe distribuído, o que o datas= do .spec já resolve sem esse passo manual.
- BlockyBee.spec: dois ajustes — datas=[('assets/app_icon_512.png', 'assets')] no Analysis(...) (para o ícone da janela funcionar também no executável empacotado, via asset_path() acima) e icon='assets/app_icon.ico' no EXE(...) (ícone do arquivo .exe em si, R21.3 — resolvido pelo PyInstaller em tempo de build, não em runtime, então não passa por asset_path()).
- No Android, asset_path() cai no branch else (nem frozen do PyInstaller nem nada especial) — Path(__file__).resolve().parent.parent resolve para a raiz do projeto tanto rodando de fonte quanto dentro do APK (o p4a preserva a árvore de arquivos Python do projeto, e source.include_exts = py,png no buildozer.spec já inclui os PNGs de assets/ no pacote). Na prática, porém, o Android não usa pygame.display.set_icon() para nada visível — a Activity não tem barra de título, e o ícone mostrado nos apps recentes/launcher vem do manifesto (seção 29.4), não de uma chamada de runtime; a chamada simplesmente não tem efeito observável lá, sem precisar de nenhum if is_android() para pular.
29.4 Integração no Android — ícone adaptativo (R21.4, R21.5, R21.6)¶
# buildozer.spec, secao [app] — caminhos relativos, mesmo estilo ja usado no
# arquivo (android.extra_manifest_xml etc.); equivalentes a %(source.dir)s/...
# ja que source.dir = . neste projeto
icon.filename = assets/android_icon_legacy.png
icon.adaptive_foreground.filename = assets/android_icon_foreground.png
icon.adaptive_background.filename = assets/android_icon_background.png
icon.filenamesozinho já cobriria todos os aparelhos (API < 26 usa direto; API ≥ 26 sem as chaves adaptativas aplicaria sua própria máscara circular default sobre esse PNG quadrado, geralmente cortando as bordas) — as duas chavesicon.adaptive_*são o que efetivamente resolve o pedido do dono do projeto ("proporções ajustadas"): o buildozer/p4a gera os recursosmipmap-anydpi-v26/icon.xml(<adaptive-icon>, referenciando as duas camadas) exigidos pelo Android 8.0+, deixando o sistema compor a máscara final a partir de uma abelha que já foi desenhada sabendo que só o círculo central de ~66% será garantidamente visível — em vez de o buildozer aplicar uma máscara genérica sobre uma imagem que não foi pensada para isso.- Sem essas chaves (comportamento antes desta task), o Android 8+ usaria o ícone quadrado como se fosse já a camada de primeiro plano inteira — a máscara circular padrão corta as pontas de qualquer conteúdo que não esteja já contido num círculo central, o que na prática cortaria as antenas/asas da abelha se ela ocupasse o quadro inteiro. É esse recorte que o dono do projeto estava descrevendo como o ícone "sem as proporções ajustadas".
icon.filename(legado) usa a composição com fundo (abelha + céu/grama), já que aparelhos API < 26 não aplicam máscara de sistema — o ícone aparece como o PNG entrega, então precisa parecer "terminado" por si só (mesmo raciocínio do ícone de janela do desktop, seção 29.3).
Verificado com build real (não apenas estático). Docker estava acessível neste ambiente (imagem kivy/buildozer já em cache local, mesma condição documentada na seção 25) e o build real (buildozer android debug) foi executado ponta a ponta, reaproveitando o cache de SDK/NDK persistido de sessões anteriores — BUILD SUCCESSFUL in 1m 36s. O comando p4a invocado pelo buildozer (visível no log) confirma a tradução das três chaves do spec para as flags reais do python-for-android: --icon .../android_icon_legacy.png --icon-fg .../android_icon_foreground.png --icon-bg .../android_icon_background.png. Além disso, o .apk gerado foi extraído (é um zip) e inspecionado diretamente: res/mipmap-anydpi-v26/icon.xml existe e contém as strings adaptive-icon/background/foreground (XML binário compilado pelo aapt, confirmando um <adaptive-icon> de verdade); res/mipmap/icon_foreground.png (432×432, RGBA) e res/mipmap/icon_background.png (432×432, RGB sem alfa) batem em dimensão e em tamanho de arquivo em bytes com os PNGs gerados por scripts/generate_app_icon.py — prova de que são exatamente os arquivos gerados, não um fallback ou o ícone padrão do template. O que não foi possível verificar neste ambiente (mesma limitação da seção 25): a composição final da máscara pelo launcher (círculo/squircle/quadrado arredondado) só é visível de fato num aparelho ou emulador Android real — o que foi confirmado é que os recursos corretos, nos tamanhos certos, com/sem alfa conforme esperado, chegam ao APK.
29.5 Testes¶
tests/test_generate_app_icon.py: chama as funções puras do script (composição do foreground, cálculo demax_sidepela zona segura,build_ico) sem depender de rodar o script inteiro como processo. Casos: a abelha do foreground cabe emSAFE_ZONE_FRACTION * FOREGROUND_CANVASem ambos os eixos; o.icogerado tem as 6 entradas esperadas e cada uma decodifica de volta para as dimensões corretas viapygame.image.load;android_icon_background.pngnão tem pixels com alfa parcial (é uma camada opaca de verdade, já que o Android a trata como fundo sólido).tests/test_assets.py:asset_path()nos dois ramos (storage.is_frozen()verdadeiro/falso via monkeypatch, mesmo padrão já usado parastorage.save_dir()emtests/test_storage.py, seção 23).- Smoke test manual (
uv run main.pye o.exegerado por PyInstaller): ambos abrem sem exceção;pyinstallerconfirma "Copying icon to EXE" no log de build.
Parte V — Blocky Bee: GPU, tela e documentação (v3)¶
30. Diagnóstico de desempenho da v2 (R27, R30)¶
Antes de otimizar, o custo por frame da v2 foi levantado lendo o src/ inteiro. O problema não era volume de conteúdo — é uma cena simples — mas desperdício estrutural, todo em CPU.
Inventário do frame (estado JOGANDO, bioma Overworld, canvas 480×720):
| Origem | Custo por frame | Onde |
|---|---|---|
| Gradiente de céu | 1 blit 480×720, com set_alpha() chamado mesmo sem fade |
biome.py draw_background |
| Parallax (2 camadas) | ~9 tiles × 3–4 draw.rect ≈ 30 chamadas, mais um random.Random(...) novo por tile por frame |
decor.py _tile/_draw_* |
| Colunas | ~3 pares × ~12 blocos ≈ 35 blits, incluindo um par inteiramente fora da tela | pipes.py PipePair.draw |
| Chão | 11 colunas × 2 fileiras = 22 blits | ground.py Ground.draw |
| Abelha | 1 transform.rotate (aloca surface nova) + get_rect() + f-string |
bird.py Bird.draw |
| Ícone de mudo | ~6 draw.rect |
ui.py draw_mute_icon |
| Escurecimento (PAUSADO/GAME_OVER) | 1 Surface((480,720), SRCALPHA) nova por frame (~1,4 MB) + fill + blit com blend |
ui.py _dim_overlay |
| Colisão | ~15 pygame.Rect alocados por frame por property |
game.py _collision_texture, bird.py, pipes.py |
| Listas | self.pipes e self.particles recriadas por frame |
pipes.py, particles.py |
Total: ~60 blits + ~36 draw.rect + ~40 objetos descartados por frame.
Três causas-raiz explicam quase tudo:
- Nada é pré-renderizado. Conteúdo idêntico frame após frame (chão, colunas, parallax, sprite rotacionado, fonte) é reconstruído do zero a cada frame. O parallax é o caso extremo:
random.Random(idx * 13 + 1)instancia um Mersenne Twister (estado de 624 palavras) por tile por frame, para produzir sempre o mesmo resultado — é determinístico poridxpor construção. - Nenhuma superfície é convertida. Não há uma única chamada de
convert()/convert_alpha()no projeto. Toda superfície nasce no formato padrão do pygame, e cada blit paga conversão de formato — inclusive as superfíciesSRCALPHAda abelha e da fonte, que caem no caminho de blend por pixel. - Tudo roda em CPU.
pygame.SCALEDfaz o SDL usar um renderizador para a escala final, mas os ~96 desenhos por frame acontecem numa superfície de software antes disso.
Some-se a isso dois problemas fora do render:
- Física travada em frame.
Game.rundescarta o retorno declock.tick(FPS)e chamaupdate()uma vez por frame desenhado. Gravidade, velocidade das colunas e temporizadores são por frame, não por segundo. Num aparelho que sustenta 40 FPS, o jogo roda a 2/3 da velocidade — câmera lenta em vez de perda de fluidez. É provavelmente a maior parte da má impressão de desempenho relatada no Android. - Escrita em disco dentro do frame.
_update_scorechamascore.save_highscore()no instante em que o recorde é superado — o que, numa partida em que o jogador está batendo o próprio recorde, é uma escrita de JSON a cada ~1,7 s, no meio do frame.
Baseline medido. Os números de referência (ms/frame p50 e p95, draw calls e alocações por cenário) são produzidos por scripts/benchmark.py (seção 39) e registrados abaixo, junto com os valores após as otimizações.
Baseline da v2 — medido na task 44, com uv run python scripts/benchmark.py --frames 300,
em Windows / Intel64 Family 6 Model 170 / Python 3.10.20 / pygame-ce 2.5.7 (SDL 2.32.10),
canvas 480×720, driver de vídeo dummy:
| Cenário | p50 (ms) | p95 (ms) | draw calls/frame | KB transitórios/frame |
|---|---|---|---|---|
| PRONTO | 3.29 | 6.64 | 73 | 3.0 |
| JOGANDO overworld | 3.47 | 6.92 | 78 | 3.0 |
| JOGANDO cave | 3.28 | 6.63 | 81 | 3.0 |
| JOGANDO nether | 3.39 | 6.54 | 61 | 2.8 |
| GAME_OVER | 4.05 | 7.51 | 86 | 3.0 |
Resultado final da v3 — medido na task 73, mesmo comando e mesma máquina do baseline
(uv run python scripts/benchmark.py --frames 300, Windows / Intel64 Family 6 Model 170 /
Python 3.10.20 / pygame-ce 2.5.7 (SDL 2.32.10), driver de vídeo dummy), mas com o canvas em
960×720 em vez de 480×720 — a v3 abre a janela de desktop mais larga que a área jogável de
propósito (R23.7), para que as faixas laterais fiquem visíveis sem redimensionar nada:
| Cenário | p50 (ms) | p95 (ms) | draw calls/frame | KB transitórios/frame |
|---|---|---|---|---|
| PRONTO | 9.64 | 11.27 | 21 | 0.6 |
| JOGANDO overworld | 9.65 | 12.00 | 17 | 0.6 |
| JOGANDO cave | 10.04 | 12.78 | 17 | 0.6 |
| JOGANDO nether | 9.90 | 15.58 | 18 | 0.6 |
| GAME_OVER | 11.21 | 14.68 | 34 | 0.6 |
O p50/p95 em ms subiu em vez de cair, e isso é esperado — não é o número que mede o
trabalho desta versão. Rodar sob SDL_VIDEODRIVER=dummy nega a criação do renderizador
acelerado (Couldn't find matching render driver, logado uma vez por cenário), então a
cascata da task 49 cai para o segundo nível — um Renderer do _sdl2 não acelerado, ainda
pago em software — e a área desenhada dobrou de largura (960 contra os 480 do baseline da v2)
para caber as faixas decorativas de R25. A task 50 já registrou essa conta na época (3,3–4,1 ms
→ 6,2–11,4 ms só de mudar o canvas, antes de qualquer otimização de draw call). O que de fato
mede o ganho da v3, e é comparável ao baseline apesar da mudança de canvas e de driver, é:
- Draw calls por frame: de ~96 (baseline da v2, canvas 480×720) para 17–34 (v3, canvas 960×720, o dobro de conteúdo) — mesmo cobrindo área dupla, o total caiu porque cada elemento pré-renderizado (chão, colunas, parallax, abelha, faixas, mobs) virou 1–2 blits em vez de dezenas (tasks 53–56). Essa é a métrica que se traduz diretamente para o aparelho real: no caminho acelerado, cada draw call é uma chamada à GPU, e é o que a task 72 existe para confirmar em hardware.
- Memória transitória por frame: de ~3,0 KB (baseline) para 0,6 KB (v3) — queda de ~80%,
sobretudo pelo fim do
random.Randompor tile por frame no parallax (task 55) e pelo cache de rotação da abelha (task 50).
Não verificável neste ambiente: o número que R27.1 realmente pede — 60 FPS sustentado —
só existe em hardware Android real, e o driver dummy não exercita o caminho acelerado nem o
custo de VSync/composição real da GPU do aparelho. Essa confirmação é o objeto da task 72,
que ficou registrada como pendente de validação manual (ver checklist "Requer aparelho Android
real" ao fim deste arquivo de tasks) pelas mesmas razões já documentadas na seção 25: não há
aparelho Android nem emulador neste ambiente de desenvolvimento.
Como ler estes números. O orçamento de um frame a 60 FPS é 16,7 ms. Num desktop x86 moderno o jogo consome ~3,4 ms de p50 — folgado. O problema é que essa conta não sobrevive à mudança de plataforma: um núcleo ARM de celular intermediário entrega tipicamente entre 1/4 e 1/6 do desempenho single-thread deste processador em código Python, o que coloca o p50 na faixa de 14–20 ms e o p95 acima de 26 ms — ou seja, abaixo de 60 FPS na mediana e com engasgo garantido na cauda. Somado à física travada em frame, isso produz exatamente o sintoma relatado: jogo lento, e não apenas com menos quadros.
O nether aparece com menos draw calls (61) que os outros biomas porque suas camadas de
parallax têm período maior (200 e 170 px, contra 220/150 no overworld e 130/100 na cave),
então cabem menos tiles na tela. É consistente com o inventário acima, e serve de conferência
de que o contador está medindo o que se espera.
Limite conhecido da métrica de memória. Os KB transitórios são medidos com tracemalloc,
que só enxerga o alocador do Python. As pygame.Surface são alocadas em C pelo SDL, então a
superfície de ~1,4 MB que _dim_overlay cria a cada frame não aparece nesta coluna — ela
se manifesta no p50/p95 do cenário GAME_OVER, que é de fato o mais lento da tabela. A coluna
mede a rotatividade de objetos Python (Rects, listas, tuplas, geradores de números
aleatórios), que é o alvo direto da seção 36.
31. Identidade: Blocky Bee (R22)¶
O personagem é uma abelha desde a v1 (textures.make_bee, seção 10); "Bird" era herança do gênero. A v3 renomeia o produto para Blocky Bee.
Pontos de troca, todos fora de specs/v1 e specs/v2 — que não são reescritos, por serem o registro histórico correto do projeto quando ele tinha outro nome (R22.4):
| Lugar | De | Para |
|---|---|---|
src/config.py TITLE |
Blocky Bird |
Blocky Bee |
src/ui.py tela inicial |
BLOCKY BIRD |
BLOCKY BEE |
buildozer.spec title |
Blocky Bird |
Blocky Bee |
buildozer.spec package.name |
blockybird |
blockybee |
| Spec do PyInstaller | BlockyBird.spec |
BlockyBee.spec |
.github/workflows/release.yml |
artefatos BlockyBird-* |
BlockyBee-* |
README.md, CLAUDE.md |
título e menções | idem |
package.domain continua com.douglaspands, então o identificador completo passa de com.douglaspands.blockybird para com.douglaspands.blockybee.
Consequência assumida (R22.3): o Android trata um applicationId diferente como um aplicativo diferente. Quem já tem o APK da v2 instalado vai receber o Blocky Bee ao lado do antigo, e o recorde salvo no armazenamento privado do antigo não é acessível ao novo — o sandbox de aplicativo do Android impede isso por design, e contorná-lo exigiria armazenamento compartilhado e permissão, preço alto por um inteiro. O comportamento resultante já é o especificado em R4.4: arquivo ausente ⇒ recorde 0.
32. Canvas lógico, área jogável e faixas decorativas (R23, R24, R25)¶
32.1 O problema real da v2¶
A v2 chamava:
self.screen = pygame.display.set_mode((SCREEN_W, SCREEN_H), pygame.SCALED | pygame.RESIZABLE)
Sem pygame.FULLSCREEN, o SDL cria uma janela do tamanho lógico (ou de um múltiplo inteiro dele) e a centraliza na tela do aparelho. No Android o resultado é o jogo desenhado num retângulo pequeno no meio, com preto sobrando nos quatro lados — que é exatamente o sintoma relatado, e é diferente do letterbox em cima/embaixo que os comentários da seção 20 descreviam.
Mesmo corrigindo isso, resta o descompasso de proporção: a área jogável é 2:3 (0,667), um celular em retrato é ~0,45 e um monitor maximizado é ~1,78. Em qualquer estratégia de "encaixar 2:3 numa tela de outra proporção" sobra barra — a menos que o que sobra deixe de ser barra.
32.2 A decisão: área jogável fixa, canvas elástico¶
Duas grandezas passam a ser distintas, e essa distinção é o coração da v3:
- Área jogável — sempre
480×720de mundo. É onde a abelha voa, onde as colunas existem e onde a colisão acontece. Nenhuma constante deconfig.pymuda (BLOCK,GRAVITY,FLAP_IMPULSE,MAX_FALL_SPEED,PIPE_SPACING,GAP_MARGIN), e nenhum parâmetro de bioma muda. É o que garante R24: o desafio é idêntico no celular e no PC, e os recordes continuam comparáveis. - Canvas lógico — tem a proporção real da tela (Android) ou da janela (desktop). Contém a área jogável e, ao redor dela, as faixas decorativas.
Foi uma escolha entre três, e as outras duas já haviam sido tentadas e revertidas na v2: esticar a área jogável (task 39, revertida) muda a dificuldade; cortar por zoom (task 40, revertida) esconde parte da cena. A terceira via — canvas elástico com área jogável fixa — preserva as duas propriedades ao custo de precisar desenhar conteúdo novo, que é justamente o que R25 pede.
Revisão (task 80): o chão passou a ter prioridade sobre a sobra vertical. A versão original desta seção mandava a maior parte da sobra vertical para o céu (teto pequeno no chão, resto sem limite para o céu). Em uso real no Android foi observada queda de FPS em telas bem alongadas, que a qualidade adaptativa (task 64, seção 38) reagia desligando decoração — o suspeito natural é o próprio céu: um gradiente do tamanho do canvas, duas camadas de parallax e um campo inteiro de mobs espalhados por uma faixa que passava de 250 px de altura num celular alto. Os tetos foram invertidos: agora é o céu que tem o teto pequeno (MAX_SKY_EXTRA, os mesmos 2 blocos que antes limitavam o chão) e o chão que absorve o restante sem limite, por ser uma faixa tileável sem gradiente nem parallax. Os mobs decorativos que viviam na faixa de céu foram para a faixa de chão estendida (seção 35). Ver a nota de task 80 em specs/v3/tasks.md para os números de antes/depois medidos por scripts/benchmark.py.
32.3 Cálculo (src/viewport.py)¶
PLAY_W, PLAY_H = 480, 720 # mundo, imutável
MAX_SKY_EXTRA = 2 * BLOCK # 96 px: teto do céu; o resto da sobra vira chão
def compute(screen_w: int, screen_h: int) -> Viewport:
aspect = screen_w / screen_h
if aspect < PLAY_W / PLAY_H: # tela mais alongada (celular em retrato)
canvas_w = PLAY_W
canvas_h = round(PLAY_W / aspect)
else: # tela mais larga (monitor)
canvas_h = PLAY_H
canvas_w = round(PLAY_H * aspect)
canvas_w = max(canvas_w, PLAY_W)
canvas_h = max(canvas_h, PLAY_H)
leftover_v = canvas_h - PLAY_H
sky_extra = min(leftover_v, MAX_SKY_EXTRA)
ground_extra = leftover_v - sky_extra
play = pygame.Rect((canvas_w - PLAY_W) // 2, sky_extra, PLAY_W, PLAY_H)
return Viewport(canvas=(canvas_w, canvas_h), play=play,
sky_extra=sky_extra, ground_extra=ground_extra)
Por construção só um dos dois eixos sobra: o canvas tem exatamente a proporção da tela, então a escala final do SDL é uniforme e preenche tudo — nenhuma barra, nenhum corte (R23.1, R23.2, R23.4).
Viewport expõe play (retângulo da área jogável dentro do canvas), sky_band, ground_band, left_band e right_band. As bandas laterais são calculadas separadamente, e não como espelho uma da outra, para absorver o pixel ímpar quando canvas_w - PLAY_W é ímpar.
Exemplos:
| Aparelho | Tela | Canvas lógico | Faixas |
|---|---|---|---|
| Celular 20:9 | 1080×2400 | 480×1067 | céu 96 px, chão extra 251 px |
| Celular 16:9 | 1080×1920 | 480×853 | céu 96 px, chão extra 37 px |
| Janela padrão do desktop | 960×720 | 960×720 | laterais de 240 px |
| Monitor 16:9 maximizado | 1920×1080 | 1280×720 | laterais de 400 px |
| Exatamente 2:3 | 480×720 | 480×720 | nenhuma |
32.4 Coordenadas: por que a física não muda¶
Todo o jogo passa a trabalhar em coordenadas de canvas, com a área jogável deslocada por play.x e play.y. Como o deslocamento é constante dentro de uma execução, todos os deltas continuam idênticos — e a física é escrita em deltas:
| Antes (v2) | Depois (v3) |
|---|---|
Bird(SCREEN_W // 4, SCREEN_H // 2) |
Bird(play.x + PLAY_W // 4, play.y + PLAY_H // 2) |
teto: if pos.y < 0: pos.y = 0 |
if pos.y < play.y: pos.y = play.y |
ground_y() = SCREEN_H - GROUND_H |
ground_y() = play.y + PLAY_H - GROUND_H |
spawn: x = SCREEN_W |
x = play.right (R24.3) |
descarte: x + PIPE_W < 0 |
x + PIPE_W < play.left |
gap_y ∈ [GAP_MARGIN, ground_y() - GAP_MARGIN] |
mesma fórmula, com o novo ground_y() |
config.SCREEN_W/SCREEN_H deixam de ser constantes de módulo e passam a ser resolvidos pelo Viewport ativo — o mesmo padrão que config.ground_y() já usava desde a v1. PLAY_W/PLAY_H assumem o papel de constante de mundo.
O teto da abelha fica na borda da área jogável, não do canvas (R24.5): a faixa de céu é decorativa e não dá espaço extra para voar.
32.5 O que vai em cada faixa¶
Faixa de céu (topo, quando a tela é mais alongada). Recebe o gradiente do bioma e as camadas de parallax, que já são desenhados na altura toda do canvas. As colunas continuam sendo desenhadas do topo do canvas para baixo, atravessando a faixa como se viessem de fora da tela. O HUD de pontuação e o ícone de mudo migram para cá quando a faixa existe e comporta o conteúdo (R25.7), o que libera a área de jogo e aproxima a ação da base da tela — melhor para o polegar em celular. Desde a task 80 a faixa tem um teto pequeno (MAX_SKY_EXTRA, 2 fileiras de bloco): é a faixa mais cara de desenhar (gradiente, parallax) e, ao contrário do chão, sem conteúdo que valha a pena esticar por muitas centenas de pixels.
Efeito colateral assumido: com céu visível acima do teto invisível da abelha, o teto fica menos evidente do que na v2 (onde coincidia com a borda da tela). Posicionar o HUD nessa faixa ajuda a lê-la como área de interface. Se a validação em aparelho mostrar que incomoda, a saída barata é uma fileira de blocos decorativa marcando o limite.
Chão estendido (base). Desde a task 80, absorve o restante da sobra vertical sem limite — o que passa do teto do céu (MAX_SKY_EXTRA) vira chão. Ground.draw já preenche de ground_y() até a base do canvas, então a faixa é consequência direta do canvas mais alto — o chão só fica mais fundo. É a faixa mais barata das duas (uma textura tileável, sem gradiente nem parallax), e é onde os mobs decorativos agora aparecem (mobs.draw_ground, seção 35) — antes viviam na faixa de céu.
Faixas laterais (quando a tela é mais larga). Cada lateral recebe um corte transversal do subsolo do bioma, ocupando a altura inteira do canvas: camadas de bloco empilhadas (Overworld grama→terra→pedra; Cave pedra→pedregulho; Nether netherrack→obsidiana) com veios de minério, reaproveitando as texturas da seção 10 e os desenhadores de minério de decor.py. A área jogável passa a ser lida como um poço vertical cortado na terra — a única parte onde se enxerga o céu. É a estética de mineshaft do Minecraft, e resolve dois problemas de uma vez: preenche a tela com conteúdo temático e é opaca.
A opacidade é o que garante R24.4. As faixas laterais são desenhadas depois das colunas, então uma coluna que ainda não entrou na área jogável fica escondida atrás da terra. Combinado com o spawn em play.right (R24.3), o jogador enxerga o obstáculo exatamente no mesmo instante em que enxergaria numa tela 2:3.
A oclusão foi escolhida em vez de
Renderer.set_viewportpor ser mais simples (nenhuma troca de estado no meio do frame) e por não ter custo — a faixa precisa ser desenhada de qualquer jeito.set_viewportfica disponível como recurso exato se algum elemento futuro vazar da coluna.
A linha do chão das faixas laterais é alinhada com ground_y() da área jogável, para a terra parecer contínua de uma borda à outra.
32.6 Ordem de desenho do frame¶
clear
gradiente do bioma (largura toda do canvas)
parallax distante/próximo (largura toda do canvas)
colunas (só na área jogável; ocultadas depois)
chão (largura toda do canvas)
mobs da faixa de chão -> sobre a textura do chão, não antes dela (task 80)
abelha
partículas
faixas laterais (opacas, cobrem o que vazou)
mobs das faixas laterais
banner de bioma
HUD (pontuação, ícone de mudo) -> na faixa de céu quando existe e comporta o conteúdo
overlay de estado (pausa / game over)
sobreposição de diagnóstico -> só com BLOCKY_PERF=1
present
Céu, parallax e chão são desenhados na largura inteira do canvas mesmo onde as faixas laterais vão cobri-los (R25.5). São 1–2 draw calls a mais de uma textura maior — irrelevante na GPU — e garante que não haja emenda visível quando uma faixa está ausente (tela exatamente 2:3) ou quando o nível de qualidade desliga alguma camada.
32.7 Criação do display e redimensionamento¶
if is_android():
pygame.display.set_mode((0, 0), pygame.FULLSCREEN) # resolução nativa (R23.3)
else:
pygame.display.set_mode((960, 720), pygame.RESIZABLE) # faixas laterais visíveis (R23.7)
O (0, 0) com FULLSCREEN faz o SDL usar o modo de vídeo corrente do aparelho, que é o que resolve a barra nos quatro lados. A janela padrão do desktop passa de 480×720 para 960×720, para que as faixas laterais apareçam sem o jogador precisar fazer nada e para que a conferência visual no PC seja representativa do celular.
WINDOWRESIZED no desktop recalcula o Viewport e reconstrói o atlas (seção 34). É trabalho de nível de inicialização — alguns milissegundos — aceitável num evento de redimensionamento, que é raro e já é um momento em que o usuário espera um repinte.
Para conferir a proporção de um celular sem aparelho, a variável de ambiente BLOCKY_CANVAS=LxA força o canvas no desktop. É ferramenta de desenvolvimento, no mesmo espírito de BLOCKY_PERF (seção 39).
32.8 Entrada¶
Sem pygame.SCALED, o SDL deixa de converter as coordenadas do mouse para o espaço lógico — na v2, input.py dependia disso (# com pygame.SCALED, event.pos já vem em coordenadas lógicas). Na v3 a conversão passa a vir do renderizador (to_logical, seção 33), que usa Renderer.coordinates_from_window no caminho de GPU e a scale.fit_scale já existente no caminho de superfície. Mouse e toque passam a usar o mesmo caminho, e input.py deixa de ter dois tratamentos diferentes.
Toque sobre faixa decorativa não é ignorado (R34.1). A regra da v2 — descartar o que caísse fora dos 480×720 — existia porque ali não havia nada: a barra de letterbox era moldura morta do SDL, e um toque nela era ruído, não intenção. Na v3 a faixa é conteúdo desenhado do jogo e, num celular 20:9, ocupa mais tela que a própria área jogável — justamente onde o polegar cai. Transportar a regra antiga transformaria a maior parte da tela em zona morta, que é o problema que esta versão se propôs a resolver.
Some, portanto, o teste contra viewport.play. Sobram dois recortes de área, ambos estreitos: o hit-test do ícone de mudo, que continua sendo exceção por estar deliberadamente posicionado na faixa de céu (R15.4, R25.7), e o descarte de coordenada fora do canvas (R34.3) — que só ocorre por arredondamento da conversão, já que o canvas cobre a tela inteira por construção (seção 32.3). Mouse e toque seguem o mesmo caminho renderer.to_logical, agora sem nenhuma regra de área divergente entre eles (R34.2): o mesmo ponto físico produz a mesma ação no celular e no PC.
33. Camada de renderização e aceleração por GPU (R26)¶
33.1 Por que uma camada¶
A v2 desenhava direto numa pygame.Surface passada de módulo em módulo. A v3 precisa de dois caminhos de desenho (GPU e superfície) sem que bird, pipes, ground, decor, particles, biome e ui saibam qual está ativo (R26.4). A solução é uma interface pequena em src/render.py, com o mínimo de operações que o jogo de fato usa:
class Image(Protocol):
size: tuple[int, int]
alpha: int # 0-255, escrita
class Renderer(Protocol):
size: tuple[int, int] # canvas lógico
backend: str # "gpu-accelerated" | "gpu-software" | "surface"
def make_image(self, surface: pygame.Surface) -> Image: ...
def clear(self, color: Color) -> None: ...
def draw(self, image: Image, dest: Rect, area: Rect | None = None) -> None: ...
def fill(self, color: Color, rect: Rect) -> None: ...
def present(self) -> None: ...
def to_logical(self, x: float, y: float) -> tuple[float, float]: ...
Sete operações cobrem o jogo inteiro. draw com area opcional é o que permite recortar uma faixa de uma strip pré-renderizada (seção 34) — no caminho de GPU vira o srcrect do Renderer.blit, e no de superfície vira o terceiro argumento de Surface.blit.
33.2 GpuRenderer¶
Construído sobre pygame._sdl2.video, disponível no pygame-ce 2.5.7 já usado pelo projeto e pela receita local do p4a (seção 24.1):
window = Window(TITLE, size=..., fullscreen=..., resizable=...) # seção 32.7
renderer = Renderer(window, accelerated=1, vsync=True)
renderer.logical_size = viewport.canvas
Corrigido na task 49. O plano original criava a janela com
pygame.display.set_mode(...)e a recuperava comWindow.from_display_module(). Não funciona: depois doset_modea janela já tem umaSurfaceassociada eSDL_CreateRendererrecusa comSurface already associated with window— medido nos driverswindowsedummy, em todas as combinações deaccelerated. No caminho de GPU a janela é criada pelo próprio_sdl2, e o módulodisplaydeixa de ser o dono dela: título, ícone e tamanho saem deWindow.title/Window.set_icon/Window.size, eRenderer.present()substituidisplay.flip(). Eventos, ciclo de vida e orientação seguem valendo sem mudança, porque a fila de eventos do SDL é global e não pertence à janela.
make_image→Texture.from_surface(renderer, surface). Todo conteúdo continua sendo gerado por código em Surfaces na inicialização (R7.1) e enviado uma vez para a GPU.draw→renderer.blit(texture, dstrect, srcrect).fill→renderer.draw_color = color; renderer.fill_rect(rect), comblend_mode = BLENDMODE_BLENDquando a cor tem alfa.Image.alpha→texture.alpha, usado pelo fade de bioma (R5.4) e pelo escurecimento das telas de pausa e game over.to_logical→renderer.coordinates_from_window(...).logical_sizefaz o próprio SDL escalar o canvas para a janela, na GPU — a mesma coisa queSCALEDfazia, mas agora com todo o desenho anterior também na GPU.
vsync=True elimina quadros desperdiçados e rasgo de imagem (R26.6). SDL_HINT_RENDER_SCALE_QUALITY=0 garante vizinho mais próximo na escala final (R26.7): é simultaneamente o mais barato e o correto para arte em pixel — filtragem linear borraria a estética voxel do jogo.
33.3 Cascata de compatibilidade¶
O projeto vai até API 21 (android.minapi), então o renderizador acelerado não é garantido. A criação é uma cascata de três níveis (R26.1–R26.3):
| Nível | Tentativa | backend |
|---|---|---|
| 1 | Renderer(window, accelerated=1, vsync=True) |
gpu-accelerated |
| 2 | Renderer(window, accelerated=-1, vsync=True) — SDL escolhe o driver, inclusive o de software |
gpu-software |
| 3 | SurfaceRenderer — pygame.SCALED e Surface.blit, o caminho da v2 |
surface |
Cada queda de nível é registrada em log com o erro que a causou, e o nível efetivo é exposto na sobreposição de diagnóstico (R26.5, seção 39). O jogo nunca deixa de abrir por causa do renderizador — mesma disciplina de degradação graciosa que o áudio já segue desde a v1 (R8.4).
33.4 SurfaceRenderer¶
Mantido não só como rede de segurança: é também o que mantém a suíte de testes simples. make_image devolve um invólucro sobre surface.convert_alpha(), draw chama Surface.blit, fill chama Surface.fill. Sob SDL_VIDEODRIVER=dummy os dois caminhos funcionam, então os testes podem exercitar ambos.
O desenho vai para um canvas fora da tela e present() o escala para a janela com pygame.transform.scale (vizinho mais próximo, mesmo critério do hint do caminho de GPU), com barras nas sobras.
Corrigido na task 49. O plano original usava
pygame.SCALEDpara essa escala. Com ele o SDL entrega o mouse já em coordenada lógica e o toque não, eto_logicalsignificaria coisas diferentes conforme o backend ativo — exatamente a assimetria que a seção 32.8 e a task 51 existem para desfazer (R34.1). Escalando à mão,to_logicalconverte pixel real de janela → canvas da mesma forma nos dois caminhos.
33.5 Impacto nos testes¶
Os módulos de desenho passam a receber Renderer em vez de pygame.Surface. tests/test_ui_layout.py, tests/test_decor.py e parte de tests/test_game.py deixam de inspecionar pixels e passam a afirmar sobre as chamadas registradas por um FakeRenderer — que além de ser mais rápido, torna as asserções legíveis ("desenhou o ícone de mudo dentro da faixa de céu" em vez de "o pixel (452, 18) é dourado").
34. Atlas: pré-renderização de tudo que é estático (R27.2)¶
O princípio é único e se aplica a tudo: se o resultado é igual entre frames, ele é construído uma vez na inicialização e vira textura. Nada de asset externo — o conteúdo continua sendo gerado por código (R7.1); o que muda é quando.
| Elemento | v2 (por frame) | v3 (por frame) |
|---|---|---|
| Gradiente de céu | 1 blit + set_alpha |
1 draw call |
| Parallax (2 camadas) | ~30 draw.rect + ~9 Random |
4 draw calls (2 por camada, com wrap) |
| Colunas | ~35 blits | 2 por coluna visível |
| Chão | 22 blits | 1 draw call |
| Abelha | rotate + get_rect + f-string |
1 draw call |
| Ícone de mudo | 6 draw.rect |
1 draw call |
| Escurecimento | Surface de 1,4 MB + blit | 1 fill |
| Faixas laterais | — | 2 draw calls |
| Mobs | — | ~2 draw calls |
Conversão (R27.4). Toda superfície passa por convert()/convert_alpha() antes de virar textura ou de ser usada no caminho de superfície. pixelfont.render monta a glifagem sob demanda e guarda em cache, então a conversão acontece no preenchimento do cache, com degradação para a superfície não convertida quando não há display inicializado — condição real nos testes, que rodam sob driver dummy.
Chão. Uma faixa de canvas_w + BLOCK de largura por par (block_main, block_edge), com a fileira de borda no topo e as de preenchimento abaixo. O rolamento vira deslocamento do retângulo de origem, não repetição de blits.
Colunas. Duas strips de altura do canvas por par de blocos — uma com a borda embaixo (coluna superior) e outra com a borda em cima (coluna inferior). Desenhar uma coluna vira recortar a faixa de altura certa com area. Some-se descarte por posição: a v2 sempre desenhava um par inteiramente fora da tela, porque _spawn cria em x = SCREEN_W (R27.7).
Parallax. Cada camada vira uma strip de period × N tiles, desenhada uma vez por bioma. Como as formas já eram determinísticas por índice (random.Random(idx * 13 + 1)), a strip reproduz exatamente o mesmo desenho — o visual é idêntico, e some toda a alocação por frame.
Abelha. O ângulo é quantizado por construção: flap fixa em +30 e a queda decrementa de 3 em 3 até −60. São no máximo 31 ângulos × 2 frames de asa = 62 texturas, pré-computadas. Bird.draw vira uma consulta e um draw call.
Faixas laterais e mobs. Uma textura por bioma para cada faixa lateral; um par de texturas (2 frames de idle) por mob. Todos construídos na inicialização e reconstruídos apenas quando o Viewport muda (redimensionamento no desktop).
35. Mobs decorativos (src/mobs.py) — R25.3, R25.4¶
Nove sprites voxel 16×16 novos em textures.py, no mesmo estilo procedural de make_bee (seção 10) — pixels posicionados por código, sem imagem externa, sem material da Mojang. Dois frames de idle cada, na mesma cadência de asa da abelha:
| Bioma | Mobs |
|---|---|
| Overworld | creeper, bruxa, aldeão |
| Cave | enderman, aranha, esqueleto |
| Nether | ghast, blaze, piglin |
Os mobs vivem exclusivamente nas faixas decorativas — a de chão estendida e as laterais — e nunca na área jogável. Desde a task 80 (seção 32.2), moraram na faixa de céu; passaram para a de chão junto com a inversão dos tetos: MobField.draw_ground substitui o antigo draw_sky, desenhando sobre viewport.ground_band em vez de sky_band, e a chamada em Game.draw migrou para depois de Ground.draw (o mob fica sobre a textura do chão, não antes dela). Têm deriva de parallax própria, mais lenta que a camada distante de decor.py, para ficarem claramente ao fundo e para não competirem com o obstáculo pela atenção do jogador.
R25.4 é a restrição que importa: eles não colidem, não pontuam, não alteram velocidade nem qualquer regra. Game._collision_texture não os consulta, e mobs.py não tem acesso ao estado de jogo — recebe apenas o Viewport, o bioma corrente e o deslocamento de parallax. É uma dependência de mão única, e é o que torna o requisito verificável por teste em vez de por inspeção.
São os primeiros a serem desligados pela qualidade adaptativa (seção 38), justamente por serem puramente decorativos.
36. Disciplina de alocação e coletor de lixo (R27.3)¶
O CPython libera memória por contagem de referências, mas objetos que participam de ciclos ficam para o coletor geracional — e uma varredura no meio de um frame é um engasgo visível. A v2 produzia ~40 objetos descartáveis por frame; a v3 ataca isso em três níveis.
Retângulos persistentes. Bird.rect, PipePair.top_rect/bottom_rect e Ground.rect eram property que construíam um pygame.Rect novo a cada acesso — e Game._collision_texture acessa bird.rect uma vez por coluna, dentro do laço. Passam a ser atributos atualizados no lugar (rect.center = ...), computados uma vez por passo de simulação.
Listas estáveis. PipeManager.update e ParticleSystem.update recriavam a lista inteira por frame para filtrar. Passam a remover no lugar, sem construir lista nova quando nada saiu.
__slots__ em Bird, PipePair e Particle: elimina o __dict__ por instância, reduz memória e acelera acesso a atributo. São classes de dados pequenas e estáveis, exatamente o caso em que __slots__ vale a pena.
Texto. ui.draw_text chamava get_rect() duas vezes por texto (sombra e frente) só para centralizar; passa a calcular a posição por aritmética. _fit_scale, que roda um laço de medição por chamada, ganha cache por (len(texto), base_size).
Ajuste do coletor (perf.tune_gc(), chamado ao fim de Game.__init__):
gc.collect()
gc.freeze() # tudo que já existe sai da varredura permanentemente
gc.set_threshold(50_000, 50, 50) # varreduras muito mais raras
gc.freeze() move os objetos já alocados — texturas, atlas, tabelas de glifos, definições de bioma — para a geração permanente, que o coletor não percorre. Como quase tudo do jogo é construído na inicialização e vive até o fim, isso remove a maior parte do trabalho de varredura de uma vez.
gc.disable() foi descartado deliberadamente: eliminaria as pausas, mas qualquer ciclo criado em tempo de execução vazaria pelo resto da sessão. Elevar o limiar mantém a rede de segurança e adia as varreduras para muito além da duração de uma partida.
Escrita em disco fora do frame (R27.5). _update_score deixa de chamar score.save_highscore() no instante da superação e passa a marcar o recorde como sujo. A gravação acontece em GAME_OVER e quando o aplicativo vai para segundo plano (APP_WILLENTERBACKGROUND, já capturado por input.py desde a v2, seção 22). Isso preserva integralmente a garantia que motivou o desenho original (R4.3, R16.4): o Android avisa antes de encerrar, e é nesse aviso que a gravação passa a acontecer.
37. Timestep fixo (R28)¶
A v2 chamava update() uma vez por frame desenhado, e a física era escrita por frame. A v3 separa simulação de desenho:
STEP_MS = 1000 / 60
MAX_FRAME_MS = 250 # limita um travamento longo (R28.3)
MAX_STEPS = 5 # evita espiral da morte (R28.2)
while self.running:
frame_ms = self.clock.tick(self.render_fps)
self.accumulator += min(frame_ms, MAX_FRAME_MS)
self.handle_events()
steps = 0
while self.accumulator >= STEP_MS and steps < MAX_STEPS:
self.update()
self.accumulator -= STEP_MS
steps += 1
if steps == MAX_STEPS:
self.accumulator = 0.0 # descarta o atraso irrecuperável
self.draw()
Três propriedades importam:
update()não muda. Continua sendo um passo lógico de 1/60 s, com as mesmas constantes. Isso é deliberado: os testes de física detest_bird.py,test_pipes.pyetest_biome.pycontinuam válidos sem uma linha de alteração, e o comportamento a 60 FPS é idêntico ao da v2 (R28.4).- O jogo não fica em câmera lenta. Num aparelho que sustenta 40 FPS, cada frame consome ~25 ms e o acumulador dispara 1 ou 2 passos — a velocidade percebida do jogo é a mesma, o que se perde é fluidez de imagem. É a diferença entre "menos quadros" e "jogo mais devagar", e era a segunda causa da má impressão de desempenho no Android.
- Uma pausa longa não vira aceleração.
MAX_FRAME_MScorta o delta antes de ele virar passos, e o teto de passos por frame impede a espiral em que atualizar demora mais que o passo simulado. R16.1 já pausa o jogo quando o aplicativo vai para segundo plano, então o clamp é a segunda linha de defesa.
render_fps é normalmente 60, e cai para 30 no nível de qualidade mais baixo (seção 38) — sem que a simulação deixe de rodar a 60 passos lógicos por segundo.
38. Qualidade adaptativa (src/quality.py) — R29¶
Nem todo aparelho vai sustentar 60 FPS mesmo depois de tudo acima. Em vez de deixar o jogo engasgar, o sistema mede e se ajusta.
Medição. Média da taxa de quadros numa janela deslizante de alguns segundos, contabilizada apenas no estado JOGANDO — as telas paradas não são representativas.
Níveis.
| Nível | O que muda |
|---|---|
| ALTO | tudo ligado (padrão) |
| MÉDIO | mobs e camada de parallax distante desligados; partículas 16 → 8 |
| BAIXO | parallax inteiro desligado (restam gradiente, faixas e chão); render a 30 FPS com simulação ainda a 60 passos lógicos; partículas 8 → 4 |
O que se desliga é sempre decorativo, em ordem crescente de importância visual. Nada que afete regra é tocado (R29.3): velocidade de coluna, tamanho de abertura, hitbox e pontuação são idênticos nos três níveis — o que preserva a comparabilidade dos recordes garantida por R24.
Histerese (R29.4). A queda de nível exige a média abaixo do limiar pela janela inteira; a subida exige uma margem folgada e uma janela mais longa. Sem isso, um aparelho no limite alternaria entre dois níveis a cada poucos segundos, que é pior visualmente do que ficar no nível mais baixo.
Persistência (R29.5, R29.6). O nível detectado é gravado ao lado do recorde, via storage.save_dir() (seção 23), e reaplicado na abertura seguinte — assim os primeiros segundos ruins acontecem uma vez, não toda vez. Arquivo ausente ou corrompido ⇒ nível ALTO e redetecção, mesma disciplina de score.load_highscore(). A gravação sai do quadro pelo mesmo caminho do recorde (seção 30, task 60): a troca de nível marca dirty, e o disco é tocado no fim da partida, na ida para segundo plano ou na saída do laço — R27.5 vale para este arquivo também, e aqui pesa mais, porque a troca de nível acontece justamente no aparelho que já está com dificuldade.
Os números. Janela de queda de 120 quadros (~2 s) contra limiar de 50 FPS; janela de subida de 300 (~5 s) contra 58 FPS. O nível grava-se pelo nome ({"level": "MEDIO"}) e não pelo inteiro do IntEnum: inserir um nível no meio da enumeração, um dia, reinterpretaria em silêncio o arquivo de quem já jogava.
39. Instrumentação e benchmark (src/perf.py, scripts/benchmark.py) — R30¶
Sobreposição de diagnóstico. FrameProfiler mantém médias móveis de tempo de update, tempo de draw e taxa de quadros, e a sobreposição os desenha com o pixelfont já existente (cacheado, seção 19), junto do backend em uso (seção 33.3). Ativada por BLOCKY_PERF=1 e desligada por padrão: quando desligada, o profiler não é instanciado e a medição não entra no caminho quente (R30.2).
Benchmark headless. scripts/benchmark.py roda sob SDL_VIDEODRIVER=dummy, sem aparelho nem janela, e mede N frames de cada cenário — PRONTO, JOGANDO nos três biomas (com colunas em tela), e GAME_OVER com partículas ativas. Reporta ms/frame p50 e p95, contagem de draw calls e alocação via sys.getallocatedblocks() e gc.get_stats().
Escolha deliberada: p50 e p95, não média. Numa aplicação de tempo real o que estraga a experiência é a cauda — um p95 ruim é um engasgo visível a cada 20 frames, e a média o esconde. As alocações entram porque são a causa mais provável dessa cauda (seção 36), então medi-las é medir o mecanismo, não só o sintoma.
Os números entram na tabela da seção 30, antes e depois, para que cada otimização seja comprovada em vez de suposta (R30.5).
40. Hints do SDL, fila de eventos e empacotamento (R26.7, R27.6)¶
Hints, definidos antes de criar o renderizador:
| Hint | Valor | Motivo |
|---|---|---|
SDL_HINT_RENDER_SCALE_QUALITY |
0 |
vizinho mais próximo: correto para arte em pixel e mais barato que filtragem (R26.7) |
SDL_HINT_RENDER_BATCHING |
1 |
agrupa draw calls consecutivas numa só submissão ao driver |
SDL_HINT_ORIENTATIONS |
Portrait |
reforça, no lado do SDL, a trava de orientação que o manifesto já declara (R23.5) |
Fila de eventos (R27.6). input.py consome QUIT, eventos de foco/ciclo de vida, KEYDOWN, MOUSEBUTTONDOWN, FINGERDOWN e eventos de joystick. Todo o resto é bloqueado com pygame.event.set_blocked. O caso que importa é FINGERMOTION: enquanto o dedo estiver na tela, o Android o emite na taxa de amostragem do digitalizador — 120 a 240 Hz em aparelhos correntes —, e cada evento vira um objeto Python que é criado, percorrido e descartado. MOUSEMOTION tem o mesmo perfil no desktop.
Mixer. A v2 deixava pygame.init() subir o mixer com os parâmetros padrão e, em seguida, SoundManager.__init__ o reinicializava com os parâmetros certos (seção 11). Passa a usar pygame.mixer.pre_init(...) antes de pygame.init(), então o mixer nasce já com a frequência e o tamanho de buffer corretos, e a inicialização acontece uma vez só.
buildozer.spec. android.archs perde x86_64 — que só serve a emulador e engorda o APK, hoje um pacote único de 62 MB com três ABIs. android.presplash_color encurta a percepção da tela preta de boot; o valor é #87CEEB, o sky_top do Overworld (biome.py), de modo que a espera entre o toque no ícone e o primeiro frame apareça como o próprio jogo abrindo e não como uma tela apagada. version vai para 0.3.0.
Registro honesto: nada disso melhora FPS. O aparelho já seleciona a melhor ABI disponível entre as embarcadas, então remover
x86_64reduz tamanho de artefato e tempo de build, não desempenho em execução. Está documentado aqui para que a expectativa não se forme errado a partir da lista de mudanças. E a redução em si só pode ser medida num build real — o número entra na task 73, junto dos demais.
41. Docstrings e site de documentação (R31)¶
Docstrings obrigatórias. As regras D (pydocstyle, convenção google) entram no [tool.ruff.lint] do pyproject.toml. O ruff já roda no CI desde a v2 (seção 26), então o gate de R31.3 não custa infraestrutura nova — é uma linha de configuração. As docstrings seguem em português, como o resto do projeto, com line-length = 110.
Site. MkDocs com tema Material e mkdocstrings[python], publicando num único lugar três coisas que hoje vivem separadas: a página de apresentação do projeto, a API extraída das docstrings de src/, e os documentos de spec de v1, v2 e v3 lado a lado. Esse último ponto é o motivo da escolha da ferramenta: num projeto cuja tese é que a spec governa o código, publicar spec e API no mesmo site é o próprio argumento — pdoc documentaria só a API.
As dependências ficam no grupo dev (R31.5). O APK embarca apenas python3, pygame-ce e android; documentação não pode entrar num artefato que roda no celular.
42. Rastreabilidade e cobertura (R32)¶
specs/v3/traceability.md tem uma linha por critério de aceitação:
| Critério | Design | Task | Testes |
|---|---|---|---|
| R24.3 | 32.4 | 47 | test_pipes.py::test_spawn_na_borda_direita_da_area_jogavel |
O ponto não é o documento — é o teste que o vigia. tests/test_traceability.py faz o parse de requirements.md, extrai todo critério RN.M e falha se algum não estiver na matriz (R32.2), e falha também se a matriz citar um teste que não existe (R32.3). Sem isso a matriz seria mais um documento que envelhece em silêncio; com isso, esquecer de rastrear um requisito novo quebra o build — que é exatamente o comportamento que se espera de um projeto guiado por spec.
Cobertura (R32.4). pytest-cov no grupo dev, com --cov=src --cov-fail-under=N. O valor de N é fixado na primeira medição, arredondado para baixo, e sobe conforme as tarefas avançam, com meta declarada de 90%. Fixar um número aspiracional antes de medir só produziria um build vermelho no primeiro commit e a tentação de desligá-lo.
43. README: motivação e método (R33)¶
O README.md ganha três blocos, reaproveitados como página inicial do site da seção 41.
Por que este projeto existe. Duas motivações, ditas sem rodeio: estudar Spec Driven Development na prática, num projeto real e pequeno o bastante para caber na cabeça; e fazer um jogo para o filho, que gosta de jogos e de Minecraft. A segunda explica escolhas concretas do projeto que de outro modo pareceriam arbitrárias — a temática, o fato de tudo ser gerado por código sem material da Mojang, e o CREDITS = "por Douglas e Pedro" que está no jogo desde a v1.
O que é SDD. Explicação curta, ancorada no que este repositório demonstra: três artefatos por versão (requirements.md em EARS, design.md, tasks.md), a spec como fonte da verdade em vez do código, uma tarefa por vez marcada [x] só depois de validada, pastas vN/ autocontidas que nunca são reescritas, e a matriz de rastreabilidade da seção 42 fechando o ciclo.
Prompt de exemplo (R33.3–R33.5). Um bloco pronto para uso que planejaria e construiria um projeto como este, combinando as duas vozes que produzem uma spec boa:
- Product Owner — objetivo em uma frase, público, user stories, critérios de aceitação em EARS, e um "fora de escopo" explícito. O fora de escopo é o que mais falta em prompt de iniciante, e é o que evita que o agente invente funcionalidade.
- Tech Lead com prática em Python — stack e versão, gerenciamento com
uv, estrutura de pacote, ferramental de qualidade como gate (ruff,ty,pytest), restrições de design (sem asset externo; nenhuma dependência que não rode no Android) e o protocolo de trabalho: gerar os três documentos antes de escrever código, implementar uma tarefa por vez, testar cada uma, marcar[x]só após validar.
Cada parte vem acompanhada de uma linha explicando por que está ali. É o que separa um prompt copiável de um prompt educativo — e, como o pedido é servir de ponto de partida para quem está começando, o prompt é escrito genérico o bastante para outro domínio, não amarrado a um jogo.
44. Boas práticas de Git e GitHub (R35)¶
O levantamento do estado do repositório (antes desta seção) mostrou duas coisas: convenções reais já em uso — branches main/release/vN/feature/<slug>, commits imperativos em português citando a task de tasks.md, tags vMAJOR.MINOR.PATCH com sufixo -RCn — e uma lista de ausências completas (LICENSE, CONTRIBUTING.md, CHANGELOG.md, .editorconfig, .gitattributes, .pre-commit-config.yaml, Dependabot, template de PR, permissions em ci.yml). A seção 42 já resolveu a rastreabilidade dentro da spec; esta resolve a higiene do repositório que hospeda a spec.
Licença: MIT, com o disclaimer de marca reforçado, não inventado. O README.md já diz, desde a v1, que todo gráfico e som é gerado por código, sem material da Mojang — a seção de licença do LICENSE/README.md só torna explícito que a licença cobre o código do projeto, nunca a marca Minecraft. MIT por ser a licença permissiva mais comum em projeto pessoal no GitHub, sem cláusula de patente que este projeto não precisa (diferença para Apache 2.0).
package-ecosystem: "uv" no Dependabot, não "pip". O suporte nativo da GitHub ao uv (GA desde março de 2025) resolve uv.lock diretamente — usar "pip" deixaria o lockfile fora do radar do Dependabot, que é exatamente o arquivo que fixa as versões resolvidas. Um segundo bloco de updates cobre package-ecosystem: "github-actions", para as versões fixadas (actions/checkout@v4 etc.) nos três workflows não envelhecerem sem ninguém notar.
ty entra no pre-commit como hook local, não como repo externo. Diferente do ruff (que tem astral-sh/ruff-pre-commit, mantido pela mesma Astral, com tags sincronizadas às releases), o ty ainda não tem um repositório de pre-commit oficial — é ferramenta nova, ainda em preview. Um hook language: system chamando uv run ty check . usa a mesma versão pinada em uv.lock que o CI já usa, em vez de abrir uma segunda fonte de verdade (uma cópia isolada do pre-commit, com sua própria versão) para a mesma checagem.
O CI passa a rodar uv run pre-commit run --all-files, além de ruff/ty/pytest separados. Não é redundância por acidente: os hooks de higiene do pre-commit/pre-commit-hooks (fim de arquivo, espaço em branco sobrando, conflito de merge não resolvido, arquivo grande demais, YAML/TOML mal formado, final de linha misto) não são cobertos por nenhum dos três gates que já existem. Sem rodar o pre-commit também no CI, essas checagens só valeriam se um contribuidor lembrasse de rodar uv run pre-commit install — e "gate opcional" não é gate, é sugestão. Os hooks de ruff-check/ruff-format reexecutam no CI por cima do que ruff check/ruff format --check já fazem; o custo é baixo (a mesma ferramenta, uma segunda vez) e a alternativa — dividir a config em "hooks só-local" e "hooks só-CI" — seria mais config para divergir silenciosamente, o mesmo tipo de duplicação que a seção 41 já evitou ao apontar docs/ para os arquivos reais em vez de copiá-los.
CHANGELOG.md não reconstrói o histórico anterior à v3. As tags existentes não mapeiam 1:1 para as pastas specs/vN/: v1.2.0 está no commit do merge de release/v2 para main (ou seja, nomeada como se fosse um incremento menor da v1, mas contém o trabalho inteiro da v2), e v0.0.1/v1.0.0 apontam para o mesmo commit. Preencher entradas retroativas detalhadas exigiria inventar uma narrativa "por versão de spec" que as tags de fato não sustentam — o tipo de imprecisão que a matriz de rastreabilidade (seção 42) existe para impedir dentro da spec, e que não faz sentido reproduzir num documento adjacente. O CHANGELOG.md segue o formato Keep a Changelog a partir de agora — uma seção [Não lançado] para a v3 em andamento — e aponta, para o que veio antes, para specs/vN/README.md e para as Releases do GitHub, que já são a fonte de verdade de cada versão encerrada.
.gitattributes normaliza para LF; só os binários reais (.png, .ico) ficam de fora. O desenvolvimento acontece no Windows (core.autocrlf=true local) e o CI roda em ubuntu-latest — sem normalização, um git diff depois de um clone novo pode mostrar o arquivo inteiro como alterado só por causa do final de linha. * text=auto eol=lf resolve isso na raiz; os únicos arquivos binários hoje versionados (ícones em assets/) precisam do marcador binary para não passarem pela normalização de texto.