Skip to content

Plano de Implementação — Blocky Bee

Tarefas incrementais; cada uma referencia os requisitos que atende. Executar em ordem — cada tarefa deixa o jogo executável.

Estado desta versão: as tasks 1–19 são o histórico já concluído na v1 (mantidas aqui para o documento ser autocontido). As tasks 20–30 são o trabalho da v2 (Android). As tasks 31–41 são aumentos de escopo posteriores da v2 (qualidade, correções pós-lançamento, o ícone do aplicativo e o ajuste final de resolução/orientação), também já concluídas. As tasks 42–73 são o trabalho da v3 — identidade, aproveitamento de tela com faixas decorativas, render acelerado por GPU, desempenho, timestep fixo, qualidade adaptativa e a camada de documentação/rastreabilidade.

As tasks 20–25 são todas implementáveis e testáveis no desktop, deliberadamente antes de mexer na cadeia de build Android — assim o risco de empacotamento (task 27, ver design seção 24.1) fica isolado no fim, e cada task anterior é verificável de imediato.

A ordem das tasks da v3 também é deliberada: renomear → medir → definir o canvas → trocar o backend de render → construir o atlas → alocação/GC → loop → adaptar → documentar. O rename vem primeiro para que nada novo nasça com o nome velho; a instrumentação vem antes das otimizações para que cada ganho seja medido em vez de suposto; o canvas vem antes do backend porque define o tamanho lógico que o renderizador recebe; e o backend vem antes do atlas porque é ele quem transforma superfície em textura. As tasks 42–71 são todas verificáveis no desktop, deixando o build Android isolado no fim (tasks 72–73), pela mesma razão da v2.

Numeração: a v2 terminou na task 41, com lacunas em 35/38/39/40 (iterações revertidas, removidas no commit 6009b5e). A v3 começa em 42 e não reaproveita as lacunas, para que o número da task continue sendo uma referência estável no histórico e nos documentos.

  • [x] 1. Esqueleto do projeto e loop básico Inicializar o projeto com uv init (pyproject.toml com pygame>=2.5 e pytest como dev; uv sync), criar estrutura de src/, config.py com constantes e main.py/game.py com janela 480×720, clock 60 FPS e loop de eventos que fecha com o X da janela. Validar uv run main.py. (R9)

  • [x] 2. Texturas procedurais base Implementar textures.py: gerador de bloco 16×16 com ruído + paletas dirt, grass_side, stone, e sprite da abelha; escala pixel-perfect para 48 px. Tela de teste exibindo os blocos. (R7.1, R7.2)

  • [x] 3. Bird com física e animação Implementar bird.py: gravidade, flap por ESPAÇO/↑/mouse, clamp no topo, rotação subida/queda, animação de asas. (R1)

  • [x] 4. Obstáculos Implementar pipes.py: spawn com abertura aleatória, movimento, remoção fora da tela, renderização como pilha de blocos com bloco de borda na abertura. (R2)

  • [x] 5. Colisão e chão Chão rolante de blocos; hitbox 85%; colisão pássaro×coluna e pássaro×chão encerra a rodada (por ora, reinicia direto). (R3.1, R3.5, R7.3)

  • [x] 6. Máquina de estados e UI Implementar GameState (PRONTO/JOGANDO/PAUSADO/GAME_OVER) e ui.py: tela inicial com idle bobbing, pausa com ESC/P, tela de game over com reinício, fonte pixelada com sombra. (R6, R3.3, R3.4, R7.5)

  • [x] 7. Pontuação e recorde Implementar score.py: +1 por coluna ultrapassada, HUD, persistência em highscore.json com fallback para arquivo ausente/corrompido, exibição do recorde no game over. (R4)

  • [x] 8. Biomas e progressão de dificuldade Implementar biome.py: os 3 biomas com thresholds 0/10/25, parâmetros de velocidade/abertura, texturas cobblestone/netherrack/obsidian, fade de céu ≤ 1 s e banner com nome do bioma. (R5, R2.5)

  • [x] 9. Decoração de fundo com parallax Duas camadas parallax por bioma: nuvens/colinas, estalactites/minérios, lava/pilares. (R7.4)

  • [x] 10. Partículas Implementar particles.py e disparar burst na colisão com cores da textura atingida. (R3.2)

  • [x] 11. Áudio sintetizado Implementar sounds.py: flap, score, hit, portal; tecla M para mudo; degradação graciosa sem mixer. Integrar aos eventos. (R8, R3.2)

  • [x] 11b. Suporte a controle de Xbox Implementar input.py (InputManager): mapear teclado/mouse/joystick para ações abstratas (A → voar/reiniciar, Start → pausar, Y → mudo), inicialização via pygame.joystick e hotplug com JOYDEVICEADDED/JOYDEVICEREMOVED. Refatorar os estados para consumir ações. (R10)

  • [x] 12. Calibração de gameplay Playtest e ajuste fino de GRAVITY, FLAP_IMPULSE, PIPE_SPACING, aberturas e velocidades por bioma até a curva de dificuldade ficar justa. (R5.3, R9.1)

  • [x] 13. Testes e verificação final Testes unitários (física, pipes, score, biomas, persistência) via uv run pytest com SDL_VIDEODRIVER=dummy; checklist manual cobrindo cada critério R1–R9; README curto com instruções de execução via uv. (R9.3, todos)

Tarefas adicionais da v1 (pós-plano original)

Pedidos feitos via chat após a conclusão do plano original (tasks 1–13). Cada uma segue o mesmo padrão: implementação, validação (testes automatizados e/ou inspeção visual) e commit.

  • [x] 14. Correção de bug: hitbox das colunas maior que o sprite PIPE_W (78 px) não batia com a largura realmente desenhada em pipes.py (BLOCK, 48 px), deixando uma faixa de colisão invisível de 30 px além do bloco visível — o pássaro colidia antes de tocar a coluna. Corrigido definindo PIPE_W = BLOCK em config.py. (R3.1, R3.5)

  • [x] 15. Decoração quadriculada (nuvens/colinas blocky) Nuvens e colinas do Overworld usavam pygame.draw.ellipse, destoando da estética voxel do resto do jogo. Substituídas por formas quadriculadas (pilhas de retângulos, com variantes seedadas por slot de parallax) em decor.py. (R7.1, R7.4)

  • [x] 16. Créditos dos criadores Nova constante CREDITS em config.py ("por Douglas e Pedro"), exibida na tela PRONTO logo abaixo do título e incluída no título da janela. (R11)

  • [x] 17. Recorde em destaque na tela inicial Exibe "RECORDE: N" no rodapé da tela PRONTO (texto dourado, sem caixa/contorno — iteração final após testar uma variante com painel), acima do chão, para aumentar a expectativa de bater o recorde antes de começar a partida. (R12)

  • [x] 18. Executável standalone (PyInstaller) BlockyBee.spec (onefile, sem console) permite gerar dist/BlockyBee.exe / dist/BlockyBee com uv run pyinstaller BlockyBee.spec, sem exigir Python nem uv na máquina de destino. pyinstaller adicionado como dependência de dev. (R13.1)

  • [x] 19. CI/CD de release (GitHub Actions) .github/workflows/release.yml: ao publicar uma Release com tag no GitHub, builda o executável para Windows e Linux (matriz de jobs) e anexa aos assets da release como BlockyBee-windows-x64-<tag>.zip e BlockyBee-linux-x64-<tag>.tar.bz2 via softprops/action-gh-release; nome do asset inclui plataforma e arquitetura. (R13.2)


Tarefas da v2 — Android (pendentes)

Fase 1 — mudanças no jogo, testáveis no desktop (tasks 20–25). Fase 2 — cadeia de build e distribuição Android (tasks 26–30).

  • [x] 20. Fonte bitmap própria, sem dependência do sistema Implementar pixelfont.py: glifos 5×7 desenhados por código para A-Z, 0-9, :, /, !, - e espaço, com render(text, scale, color) e cache. Trocar o SysFont("couriernew") de ui.py por essa fonte, preservando a sombra dura e a hierarquia de tamanhos das telas atuais. Validar visualmente que PRONTO, HUD, PAUSADO e GAME_OVER continuam legíveis e centralizados. (R7.6, R7.5, R14.5) Ajuste feito na implementação: a fonte bitmap é proporcionalmente mais larga que a SysFont antiga — mapear base_size direto para escala fixa estourava a largura da tela em 3 textos reais (BLOCKY BEE 585px, ESPACO / CLIQUE PARA VOAR 745px, ESPACO / CLIQUE PARA REINICIAR 716px, todos > 480px). Adicionado ui._fit_scale(): reduz a escala automaticamente até o texto caber em SCREEN_W - 40, verificado para todas as strings reais do jogo.

  • [x] 21. Resolução lógica escalável com letterbox Passar pygame.SCALED | pygame.RESIZABLE no set_mode e verificar que o jogo mantém proporção ao redimensionar a janela (barras nas laterais/topo, sem distorção nem deslocamento do gameplay). Confirmar que o clique do mouse continua batendo com o que se vê. Atenção — quebra conhecida: SCALED sob SDL_VIDEODRIVER=dummy só permite um set_mode por processo, e tests/test_game.py cria Game() em 6 testes, então a suíte quebra sem ajuste. Incluir nesta task a fixture de pygame.display.quit() + init() no conftest.py (solução já verificada, ver design seção 20.2) e deixar a suíte passando. (R9.1, R14.3) Validado: janela real redimensionada para 900×500 (bem diferente de 480×720) mostrou pillarbox correto, sem distorção. Tradução de coordenada do mouse confirmada por instrumentação direta (script que registra event.pos): clique na coordenada lógica alvo (5,5) numa janela com offset de pillarbox de 202px foi reportado por pygame como pos=(5, 5) — exatamente client_x - offset_x; clique na barra preta (fora da área lógica) reportou pos=(-101, 360), coordenada negativa fora da faixa válida, confirmando que o pygame.SCALED já entrega tudo em espaço lógico sem esforço adicional.

  • [x] 22. Armazenamento do recorde por plataforma + gravação incremental Implementar storage.py (is_android(), save_dir()) e apontar score.py para ele; mudar a gravação do recorde para o instante em que o score ultrapassa o recorde, em vez de só no GAME_OVER. Testes para os dois caminhos de plataforma e para a gravação incremental. (R4.3, R4.5, R16.3, R16.4) Ajustes feitos na implementação:

  • score.py resolve o caminho padrão dentro do corpo da função (_default_path()), não como valor de default de parâmetro (path: Path = HIGHSCORE_PATH) — um default de parâmetro é calculado uma única vez na importação do módulo, o que "congelaria" storage.save_dir() e tornaria o comportamento impossível de testar via monkeypatch (ou de reagir a uma mudança de plataforma em runtime).
  • Por consequência, a fixture _isolate_cwd do conftest.py (que já isolava highscore.json via chdir) precisou de um monkeypatch.setattr("src.storage.save_dir", lambda: tmp_path) — storage.save_dir() no desktop resolve a raiz do projeto via __file__, não mais o cwd, então só o chdir não bastava. Confirmado que sem esse ajuste os testes voltavam a escrever highscore.json na raiz real do projeto.
  • Essa mesma fixture autouse, por sua vez, impedia testar a implementação real de storage.save_dir() em tests/test_storage.py — resolvido chamando monkeypatch.undo() no início desses testes especificamente, revertendo a proteção só ali.
  • Validado fora do pytest também: rodando o jogo de verdade, o highscore.json é gravado na raiz real do projeto assim que o score ultrapassa o recorde, ainda em JOGANDO (antes de qualquer colisão).

  • [x] 23. Entrada por toque e botão BACK Tratar FINGERDOWN em input.py com conversão de coordenada normalizada → espaço lógico (desfazendo o letterbox), K_AC_BACK como ação back, e o desvio por estado no Game (pausa em JOGANDO, encerra fora dele). Adicionar o ícone de mudo tocável no canto da tela. Testar com eventos sintéticos, incluindo toque nas barras (deve ser ignorado). (R15.1, R15.2, R15.3, R15.4) Ajuste feito na implementação: o design previa só FINGERDOWN fazendo hit-test do ícone de mudo, mas o próprio design nota que o SDL sintetiza MOUSEBUTTONDOWN a partir do toque real — sem tratamento, tocar no ícone no Android dispararia flap também pelo evento de mouse sintético. Unificado num _handle_tap() compartilhado, usado tanto por MOUSEBUTTONDOWN (que já chega em coordenadas lógicas via SCALED) quanto por FINGERDOWN (convertido manualmente). ui.MUTE_ICON_RECT foi colocado em ui.py (onde o ícone é desenhado) e importado por input.py para o hit-test, mantendo desenho e geometria juntos.

  • [x] 24. Pausa automática ao perder foco (ciclo de vida) Tratar APP_WILLENTERBACKGROUND/APP_DIDENTERBACKGROUND (e WINDOWFOCUSLOST como fallback de desktop) como ação focus_lost, levando JOGANDO → PAUSADO e nunca retomando sozinho. Testável no desktop com alt-tab. (R16.1, R16.2) Validado por evento sintético (pytest), não por alt-tab real: a lógica (JOGANDO→PAUSADO ao perder foco, sem retomada automática, ignorado fora de JOGANDO, cobrindo tanto WINDOWFOCUSLOST quanto APP_WILLENTERBACKGROUND) está coberta por 4 testes com eventos postados diretamente. A tentativa de reproduzir com uma janela real e alt-tab neste ambiente (troca de foco via SetForegroundWindow) ficou inconclusiva — o pássaro chegou a GAME_OVER por simplesmente não receber nenhum flap durante os segundos do teste, independente do evento de foco ter disparado ou não; não há como isolar as duas causas de fora do processo sem instrumentação adicional. Como o código testado é exatamente o mesmo caminho que um alt-tab real percorre, considero a lógica coberta, mas recomendo uma checagem manual rápida (abrir o jogo, voar, alt-tab, voltar) antes de publicar.

  • [x] 25. Mapeamentos adicionais de teclado e correção de alcançabilidade em PAUSADO Mapear K_RETURN/K_KP_ENTER para flap e adicionar o mudo por setas ←/→ no overlay de PAUSADO, com a dica escrita na tela. Revisar que todo estado é alcançável sem toque e sem gamepad. (R15.4, R15.5) Bug real encontrado na revisão de alcançabilidade (o próprio ponto que esta task pedia para checar): _flap_action() não tratava PAUSADO, e o BACK em PAUSADO encerra o jogo (não despausa) — então quem pausasse via BACK (sem tecla ESC/P nem botão Start de gamepad) ficava sem nenhuma forma de despausar, só de sair. O mesmo valia para quem só usa toque no celular. Corrigido fazendo _flap_action() também despausar (sem flapar o pássaro) quando PAUSADO. Validado de ponta a ponta com test_bare_tv_remote_reaches_every_state: simula um perfil de entrada básico (só setas, ENTER e voltar — sem toque, sem tecla M/ESC/P, sem gamepad) passando por todos os estados do jogo.

  • [x] 26. Migração de pygame para pygame-ce Trocar a dependência no pyproject.toml, recriar o ambiente (uv sync), rodar a suíte completa e validar o jogo no desktop. Nenhum import muda. Pré-requisito da cadeia de build Android. (R9.2) uv sync trocou limpo (desinstalou pygame==2.6.1, instalou pygame-ce==2.5.7, que também trouxe SDL 2.32.10 — versão mais nova que a 2.28.4 anterior). Isso quebrou 9 testes: sob SDL_VIDEODRIVER=dummy, pygame.display.set_mode(..., SCALED) no SDL novo enfileira uma sequência de eventos de janela (WindowShown, WindowFocusGained/Lost, ActiveEvent etc.) que não existia na versão antiga — incluindo um WindowFocusLost genuíno, que contaminava o poll() seguinte com uma ação focus_lost espúria. Confirmado que é só um artefato do driver dummy: rodando com driver de vídeo real, a mesma criação de janela não gera nenhum WindowFocusLost (só eventos neutros como WindowShown/MouseMotion). Corrigido com pygame.event.clear() logo após o set_mode(), tanto em Game.__init__ (defensivo, produção) quanto nos testes que criam InputManager diretamente. Reconstruí o executável (BlockyBee.spec) e confirmei que ainda empacota e roda normal com pygame-ce.

  • [x] 27. buildozer.spec e receita local do pygame-ce Criar buildozer.spec (minapi 21, api 34, três ABIs, orientação retrato, fullscreen) e a receita local em p4a-recipes/pygame-ce/. Esta é a task de maior risco (ver design seção 24.1: a receita não está mergeada no p4a upstream) — atacar cedo dentro da fase 2 e, se a receita não compilar, fixar a versão de pygame-ce conhecida como funcional. (R17.1, R14.1, R14.2) Estado: buildozer.spec fixa p4a.branch = v2024.01.21 (última release do p4a antes de o hostpython3 passar a Python 3.14, que quebra o setup.py de todas as versões testadas do pygame-ce — distutils.ccompiler.spawn removido no Python 3.12+; achado real, documentado no próprio buildozer.spec e no design seção 24.1). Ajuste feito na revisão desta task: a receita local (copiada do PR upstream kivy/python-for-android#2971) vinha fixada em pygame-ce==2.4.0, desalinhada da versão 2.5.7 já validada no desktop (task 26). Corrigido para version = '2.5.7' (tag confirmada existente no repositório pygame-community/pygame-ce), evitando ter duas versões de pygame-ce diferentes em voo entre desktop e Android; se essa versão não compilar no p4a (só verificável na task 28, sem Docker/Android neste ambiente — ver design seção 25), a saída documentada é fixar aqui a última versão conhecida como funcional. Não verificável neste ambiente: compilação real da receita (exige p4a + NDK/SDK Android, ver design seção 24.1/25) — fica para a task 28. buildozer.spec foi validado apenas como INI bem formado (configparser) e pela suíte pytest completa (63 testes, inalterada por esta task, já que nenhum código do jogo mudou).

  • [x] 28. Build local do APK e primeira instalação real Gerar o APK via container Docker do Buildozer, instalar em aparelho físico e validar o loop básico (abre, joga por toque, som, recorde persiste após fechar e reabrir). Primeiro ponto em que o jogo roda de fato no Android. (R17.1, R14.1) Estado: Docker ficou disponível neste ambiente (ao contrário do que o design.md/task 27 assumiam) e o build real via docker run kivy/buildozer android debug foi executado até BUILD SUCCESSFUL, gerando bin/blockybee-0.2.0-armeabi-v7a_arm64-v8a_x86_64-debug.apk (62 MB, as 3 ABIs). Vários bugs reais só apareciam nesta etapa (nunca antes exercida) e foram corrigidos:

  • Cache .buildozer de uma tentativa anterior tinha hostpython3 compilado como CPython 3.14 em vez do 3.11.5 esperado do pin p4a.branch = v2024.01.21 — cache limpo para forçar reclone correto.
  • docker run -v "$(pwd):..." a partir do Git Bash montava um volume anônimo vazio em vez do diretório do projeto (path mangling do MSYS) — corrigido com MSYS_NO_PATHCONV=1.
  • O cache real do Android SDK/NDK do buildozer vive em $HOME/.buildozer dentro do container (efêmero a cada docker run --rm), mas o marcador "já instalado" fica em .buildozer/state.db do projeto (persistido via bind mount) — a inconsistência fazia platforms;android-34 nunca ser reinstalado em containers novos ("Available Android APIs are ()"). Corrigido montando um diretório persistente do host (~/.buildozer-android-global-cache) também em /home/user/.buildozer.
  • p4a-recipes/jpeg/__init__.py (nova receita local): o CMakeLists.txt do libjpeg-turbo 2.0.1 exige cmake_minimum_required < 3.5, incompatível com o CMake 4.2.3 do container — corrigido com -DCMAKE_POLICY_VERSION_MINIMUM=3.5 direto na chamada (variável de ambiente via docker -e não chega ao subprocesso, pois Arch.get_env() do p4a monta o ambiente do zero); rm -f trocado por rm -rf para sobreviver a retries.
  • p4a-recipes/pygame-ce/__init__.py: faltava 'cython' em depends (a cópia do PR upstream não declarava, ao contrário de outras receitas do p4a que compilam .pyx) — setup.py build_ext falhava com "You need cython".
  • p4a-recipes/pygame-ce/__init__.py: sdl_image_includes apontava para a raiz de jni/SDL2_image, mas a versão do sdl2_image (2.8.0) move o header público para jni/SDL2_image/include/SDL_image.h (diferente do SDL2_ttf, que mantém SDL_ttf.h na raiz) — src_c/imageext.c falhava com "'SDL_image.h' file not found". Não verificável neste ambiente: instalação e playtest em aparelho físico (sem Android real disponível) — pendente de validação manual pelo dono do projeto antes de publicar.

  • [x] 29. Job de CI do APK na Release Acrescentar ao release.yml um job ubuntu-latest independente que builda o APK em Docker, com cache de ~/.buildozer, e anexa BlockyBee-<tag>.apk sem compressão aos assets — mantendo os dois assets de desktop já existentes. (R17.2, R13.3) Implementação: job build-apk em .github/workflows/release.yml, independente do job build (matriz Windows/Linux) para que uma falha na cadeia Android (a parte mais frágil, task 27) não impeça a publicação dos executáveis de desktop. docker pull kivy/buildozer + docker run … kivy/buildozer android debug, com yes y | porque a imagem recusa rodar como root e porque o primeiro build precisa aceitar as licenças do Android SDK interativamente. APK renomeado para BlockyBee-<tag>.apk e publicado via softprops/action-gh-release@v2, que copia o arquivo como está (o .apk já é um zip; a action não o recomprime). Ajuste feito na implementação: o docker run inicial só montava ${{ github.workspace }}:/home/user/hostcwd, sem o segundo volume documentado pela própria imagem oficial (kivy/buildozer no Docker Hub) para persistir cache entre execuções — -v "$HOME/.buildozer":/home/user/.buildozer, onde SDK/NDK baixados ficam guardados fora do diretório do projeto. Sem esse mount, o cache de actions/cache no path ~/.buildozer (pedido pelo design, seção 24.3) sempre voltaria vazio, forçando o download completo de SDK+NDK (30–60 min) em toda execução. Corrigido adicionando os dois volumes e cacheando ambos os paths (~/.buildozer e .buildozer) na mesma entrada de actions/cache. Não verificável neste ambiente: execução real do job (exige Docker + uma Release publicada no GitHub, ver design seção 25) — validado apenas como YAML bem formado (yaml.safe_load) e por leitura cruzada com a documentação oficial da imagem kivy/buildozer para confirmar os volumes/paths de cache corretos. Fica para a task 30 (ou uma release de teste) confirmar que o job efetivamente builda e anexa o APK.

  • [x] 30. Ajuste de áudio/performance no Android e verificação final da v2 Ajustar o buffer do mixer para Android e medir o tempo de frame em aparelho de entrada; completar o checklist manual em celular; atualizar o README com instruções de instalação do APK. (R8.4, R9.5, R14.6, todos) Implementado e verificável neste ambiente: sounds.py agora chama pygame.mixer.init(..., buffer=1024) quando storage.is_android() é verdadeiro (mantendo o default do pygame no desktop, sem regressão), conforme o valor inicial documentado no design (seção 11) — 2048 fica como próximo passo caso o playtest real em aparelho ainda acuse estouro/crepitação com 1024. Coberto por tests/test_sounds.py (2 testes, mockando is_android e pygame.mixer.init para inspecionar os kwargs passados). README atualizado com seção "Instalar no Android" (fontes desconhecidas, controles por toque/BACK, requisito de API 21) e o comando docker run para reproduzir o build do APK localmente. Suíte completa: 65 testes passando. Não verificável neste ambiente (sem Android real nem emulador, ver design seção 25): medir o tempo de frame em aparelho de entrada (R9.5), confirmar que buffer=1024 de fato elimina estouros/crepitação no hardware real (R8.4) — se não eliminar, subir para ANDROID_MIXER_BUFFER = 2048 em sounds.py — e os itens de checklist abaixo marcados como "requer aparelho Android real". Ficam pendentes de validação manual pelo dono do projeto antes de considerar a v2 encerrada.

Correção pós-lançamento — Tela cheia no Android sem pillarbox, sempre em retrato

Pedido do dono do projeto: no Android, a tela deve ser preenchida sem barra visível nas laterais, e o jogo deve rodar sempre em retrato (nunca em paisagem, mesmo que o aparelho gire).

  • [x] 41. Travar orientação retrato e preencher a tela via letterbox (pygame.SCALED) buildozer.spec: orientation = portrait, fullscreen = 1. src/game.py::Game.__init__ usa pygame.SCALED | pygame.RESIZABLE (mesmo mecanismo da task 21) sem nenhum branch por plataforma — resolução lógica fixa 480×720 em qualquer aparelho, escalada para a janela/tela real mantendo a proporção; o excedente de um dos eixos vira barra de letterbox, nunca corta a imagem. src/scale.py::fit_scale(canvas_w, canvas_h, window_w, window_h) calcula a escala (mínimo dos dois fatores de eixo) e os offsets do letterbox, usado por input.py para converter toque (FINGERDOWN) em coordenada lógica — MOUSEBUTTONDOWN já chega pré-convertido pelo próprio pygame.SCALED. Toque na barra de letterbox é ignorado (não dispara flap/mute). Ver design.md seção 20. (R9.1, R14.3, R14.4, R15.1, R15.4) Por que travar a orientação elimina o pillarbox: com pygame.SCALED (escala = menor dos dois fatores de eixo), pillarbox (barra lateral) só ocorre quando a janela é proporcionalmente mais larga que o jogo (2:3). Travado em retrato, a janela do Android nunca fica mais larga que alta — a esmagadora maioria dos celulares é mais alongada que 2:3 — então a barra que sobra é sempre letterbox (topo/base), nunca pillarbox. Testes: tests/test_scale.py (fit_scale — janela mais larga → letterbox vertical, mais alta → letterbox horizontal, e a invariante de que o canvas escalado nunca excede a janela). tests/test_game.py::test_screen_uses_fixed_logical_resolution_via_scaled (confirma game.screen.get_size() == (480, 720), independente de plataforma). tests/test_input.py::test_finger_tap_outside_logical_area_is_ignored (toque na barra de letterbox é ignorado). Suíte completa (86 testes) verde; ruff check/ruff format --check/ty check sem violações. Validado com uv run main.py (driver de vídeo real, não headless): janela abre em 480×720 sem exceção. Não verificável neste ambiente: confirmar visualmente em celular Android real que a orientação fica travada em retrato e que não sobra pillarbox nas laterais (mesma limitação da seção 25) — fica pendente de validação manual do dono do projeto.

Checklist de verificação manual (task 13 + tarefas adicionais)

Itens marcados [x] foram validados automaticamente (suíte pytest ou scripts de verificação usados durante o desenvolvimento das tasks 1–19), não por um humano jogando de fato. Recomenda-se uma passada manual real antes de publicar, em especial para os itens de áudio e controle físico (marcados abaixo), que dependem de percepção humana e de hardware que não está disponível neste ambiente.

  • [x] Flap responde a ESPAÇO, ↑, clique e botão A do controle (R1.1, R10.3) — testado com eventos simulados; botão A do controle não testado com hardware real
  • [x] Start pausa, Y muta; conectar/desconectar controle durante o jogo não trava (R10.2, R10.3) — hotplug testado com joystick simulado, não com controle físico
  • [x] Jogo funciona normalmente sem controle conectado (R10.5)
  • [x] Pássaro não morre no teto, morre no chão e nas colunas (R1.4, R3.1); colisão com coluna só ocorre no bloco visível (R3.1, task 14)
  • [x] Ponto único por coluna, com som (R4.1) — som verificado via chamada de play(), qualidade sonora não avaliada por ouvido humano
  • [x] Recorde sobrevive a reinício do jogo (R4.3, R4.4)
  • [x] Biomas trocam em 10 e 25 pontos com fade e banner (R5); nuvens/colinas do Overworld são quadriculadas, não elipses (R7.1, R7.4, task 15)
  • [x] Pausa/despausa com ESC/P; reinício após game over (R6)
  • [x] Partículas na colisão (R3.2); mudo com M (R8.3)
  • [x] 60 FPS estáveis (~4.6 ms/frame médio, calibração da task 12); inicia com uv run main.py após uv sync (R9)
  • [x] Créditos ("por Douglas e Pedro") visíveis na tela PRONTO e no título da janela (R11)
  • [x] Recorde exibido no rodapé da tela PRONTO, acima do chão (R12)
  • [x] uv run pyinstaller BlockyBee.spec gera um executável que abre com duplo clique, sem Python instalado (R13.1) — testado localmente no Windows; build Linux não testado neste ambiente (sem Linux disponível)
  • [x] Publicar uma Release de teste no GitHub e confirmar que os assets BlockyBee-windows-<tag>.zip e BlockyBee-linux-<tag>.tar.bz2 aparecem automaticamente (R13.2) — Release v1.0.0 publicada em douglaspands/blocky-bird-game, com BlockyBee-linux-v1.0.0.tar.bz2 e BlockyBee-windows-v1.0.0.zip anexados automaticamente pelo github-actions[bot], confirmado via API pública do GitHub

Checklist de verificação da v2 (Android)

Ver design seção 25 para o motivo da separação. Os itens de hardware real não podem ser validados no ambiente de desenvolvimento e exigem teste manual do dono do projeto — não marcar sem ter testado de fato no aparelho.

Verificável automaticamente / no desktop:

  • [x] Todas as telas renderizam com a fonte bitmap própria, sem SysFont, mantendo alinhamento (R7.6) — confirmado por inspeção (grep SysFont src/: só ocorre em comentários explicativos, nenhum uso real) e visualmente durante a task 20
  • [x] Redimensionar a janela do desktop reamostra o letterbox automaticamente (pygame.SCALED), preservando a proporção do jogo (não da tela) e sem distorcer nem deslocar o gameplay (R9.1, R14.3) — validado com janela real redimensionada ao vivo para tamanhos bem diferentes da base 480×720
  • [x] Toque/clique em coordenada normalizada converte corretamente para o espaço lógico, respeitando o letterbox; toque na barra é ignorado (R15.1) — tests/test_input.py::test_finger_tap_in_game_area_flaps, test_finger_tap_outside_logical_area_is_ignored
  • [x] storage.save_dir() devolve o caminho Android quando ANDROID_ARGUMENT está definido e o caminho do projeto quando não está (R4.5) — tests/test_storage.py
  • [x] Recorde é gravado no momento em que o score ultrapassa o recorde, não só no GAME_OVER (R4.3, R16.4) — tests/test_game.py::test_highscore_saved_incrementally_mid_round
  • [x] Ação back pausa em JOGANDO e encerra nos outros estados (R15.2, R15.3) — tests/test_game.py::test_back_in_*
  • [x] Perder foco da janela (alt-tab) leva JOGANDO → PAUSADO e não retoma sozinho (R16.1, R16.2) — tests/test_game.py::test_focus_lost_*
  • [x] K_RETURN dispara flap (equivalente ao botão central de controle remoto) (R14.4) — tests/test_input.py::test_return_and_kp_enter_flap
  • [x] Suíte pytest completa continua passando após a migração para pygame-ce (R9.2) — 65 testes, SDL_VIDEODRIVER=dummy uv run pytest
  • [x] A área jogável é sempre 480×720 fixo, em qualquer aparelho (calibração de dificuldade da task 12 nunca muda); pygame.SCALED aplica letterbox automático sem cortar a imagem, e a orientação fica travada em retrato no Android (R14.3, R14.4) — tests/test_scale.py, tests/test_game.py::test_screen_uses_fixed_logical_resolution_via_scaled

Requer aparelho Android real (celular):

  • [x] APK instala por download direto, com "fontes desconhecidas", sem descompactar nada (R17.1, R17.2)
  • [x] Toque em qualquer ponto faz o pássaro voar; iniciar e reiniciar funcionam por toque (R15.1)
  • [x] Ícone de mudo responde ao toque e silencia de fato (R15.4)
  • [x] BACK pausa durante o jogo e encerra o app nas telas de PRONTO/PAUSADO/GAME_OVER (R15.2, R15.3)
  • [x] Trocar de app / receber ligação pausa automaticamente; ao voltar continua pausado (R16.1, R16.2)
  • [x] Recorde sobrevive a fechar e reabrir o app, e a encerramento forçado pelo sistema (R4.5, R16.4)
  • [x] A orientação fica travada em retrato mesmo girando o aparelho, e a imagem preenche a tela sem pillarbox nas laterais (letterbox no topo/base é aceitável, desde que a imagem inteira continue visível) (R14.3, R14.4)
  • [x] Áudio sem estouros/crepitação (R8.4, buffer do mixer)
  • [x] 60 FPS em aparelho de entrada (R9.5)
  • [x] Ícone do launcher mostra a abelha inteira, sem cortar antenas/asas, em qualquer forma de máscara do fabricante (R21.4, R21.5) — task 37

Tarefas adicionais da v2 (pós-Android) — Qualidade

Aumento de escopo pedido depois que as tasks 20–30 (Android) já estavam concluídas: conformidade com ruff em todo o código Python, calibração do tamanho de fonte para eliminar sobreposição de texto nas telas, correção de bug de persistência do recorde no executável empacotado, e conformidade com ty (checagem de tipos).

  • [x] 31. Conformidade com Ruff Adicionar ruff como dependência de dev (uv add --dev ruff), configurar [tool.ruff] em pyproject.toml (line-length = 110, target-version = "py310", regras E, F, W, I, UP, B, SIM, RUF, ver design seção 26). Rodar uv run ruff check --fix . e uv run ruff format . sobre main.py, src/, tests/, scripts/, p4a-recipes/; revisar manualmente qualquer correção automática que mude comportamento (não só estilo) antes de aceitar. Corrigir à mão o que --fix não resolver. Criar .github/workflows/ci.yml (on: push, pull_request) rodando ruff check, ruff format --check e pytest (SDL_VIDEODRIVER=dummy) no mesmo job. Confirmar uv run pytest completo continua verde após as correções de lint. (R18) Ajuste feito na implementação: uv run ruff format . reformata por padrão também blocos de código Python dentro de cercas ```python em arquivos .md (comportamento desta versão do ruff, 0.16.0) — isso reformatou specs/v1/design.md, violando a regra do próprio projeto de nunca editar pastas de versões anteriores já concluídas (specs/README.md). Corrigido com extend-exclude = ["specs"] em [tool.ruff], restringindo a varredura de fato ao código do jogo (o escopo já pretendido pelo design, que nunca mencionava specs/). Violações reais encontradas (15 no total, 7 corrigidas por --fix, 8 à mão): auto-fix foi só estilo (ordenação de imports em p4a-recipes/jpeg/__init__.py, .format() → f-string, noqa órfão, typing.Callable → collections.abc.Callable em decor.py). À mão: RUF012 (atributos de classe mutáveis built_libraries/depends nas receitas p4a — anotados com ClassVar, já que são o padrão de configuração do próprio framework p4a, não um bug real); SIM115 (dois open() sem context manager em p4a-recipes/pygame-ce/__init__.py — convertidos para with); B905 (dois zip() sem strict= em src/biome.py e scripts/generate_app_icon.py — ambos combinam tuplas RGB de tamanho fixo e igual, strict=True é correto e não muda comportamento); E501 (duas docstrings de uma linha em src/biome.py/src/pipes.py acima de 110 colunas — quebradas em docstring de duas linhas, sem alterar o texto). Suíte completa (65 testes) permanece verde após todas as correções.

  • [x] 32. Calibração de tamanho de fonte sem sobreposição Implementar ui._stack() (empilhamento vertical por altura real de linha, ver design seção 27) e migrar draw_ready_screen, draw_paused_overlay e draw_game_over_screen para usá-lo em vez dos deltas fixos em pixels atuais. Revisar visualmente (uv run main.py) o base_size/escala de cada papel de texto (título, créditos, instrução, HUD, overlay de pausa, textos de game over, recorde) até nenhuma sobreposição ser visível em nenhuma das quatro telas, registrando os valores finais escolhidos. Criar tests/test_ui_layout.py com um teste por tela que renderiza os textos reais (incluindo RECORDE: 999999 para o caso de recorde com muitos dígitos) e assere que nenhum par de retângulos se sobrepõe e que todos ficam dentro de [20, SCREEN_W - 20] horizontalmente e fora da área do chão verticalmente; incluir o retângulo do ícone de mudo (ui.MUTE_ICON_RECT) na checagem das telas onde ele aparece. (R19) ui._stack(surface, center_x, top_y, lines, margin=8) implementado: recebe (text, base_size, color) na ordem de exibição, calcula a escala real de cada linha via _fit_scale/_scale_for já existentes, delega o desenho a draw_text (mesma função usada fora do stack, o que manteve um único ponto de instrumentação para os testes) e avança a posição vertical por GLYPH_H * escala + SHADOW_OFFSET + margin — nunca por uma constante escolhida a olho. base_size finais escolhidos (calibração visual via screenshots offscreen com as telas reais, incluindo RECORDE: 999999): título (BLOCKY BEE/PAUSADO/GAME OVER) base_size=12 (escala 6, contra 18/escala 9 antes); texto secundário de destaque (PONTOS/RECORDE no game over) base_size=8 (escala 4, igual ao recorde da tela PRONTO); instrução/dica (ESPACO / CLIQUE PARA VOAR, ESPACO / CLIQUE PARA REINICIAR, SETAS: MUDO) base_size=5-6 (escala 2-3); créditos base_size=5 (escala 2); HUD de score base_size=12 (escala 6, reduzido de 20/escala 10 — o valor antigo já quase tocava o topo da tela). Nenhuma sobreposição visível nas quatro telas nem no pior caso (RECORDE: 999999); confirmado também com um smoke test real (uv run main.py) sem exceptions. tests/test_ui_layout.py: _rects_for_screen monkeypatcha ui.draw_text (interceptado também dentro de _stack, já que é a mesma função do módulo) para capturar, por linha, um pygame.Rect de texto+sombra sem duplicar a lógica de renderização. Um teste por tela (PRONTO, HUD, PAUSADO, GAME_OVER) chama isso com os textos reais — incluindo 999999 — e verifica ausência de sobreposição (incluindo contra ui.MUTE_ICON_RECT, presente em todas as telas pois game.py o desenha incondicionalmente) e que cada retângulo de texto fica em [20, SCREEN_W - 20] horizontalmente e acima de GROUND_Y verticalmente. MUTE_ICON_RECT entra só na checagem de colisão, não na de limites horizontais — ele fica a propósito perto da borda direita (dentro da margem de 14px do ícone, não da margem de 20px do texto). Suíte completa: 69 testes (65 + 4 novos), SDL_VIDEODRIVER=dummy uv run pytest.

  • [x] 33. Corrigir persistência do recorde no executável Windows/Linux empacotado Bug reportado pelo dono do projeto: no executável Windows gerado por BlockyBee.spec, o recorde deixou de persistir e highscore.json não era mais criado ao lado do .exe. Causa raiz: storage.save_dir() resolvia o diretório desktop a partir de Path(__file__).resolve().parent.parent, mas o PyInstaller empacota em modo onefile (EXE(pyz, a.scripts, a.binaries, a.datas, ...) numa única chamada), e nesse modo __file__ do módulo aponta para o diretório temporário de extração (sys._MEIPASS), apagado ao fechar o processo — nunca para a pasta real do executável. O mesmo problema afeta o build Linux (mesmo .spec), ainda não reportado. Corrigido em storage.py com is_frozen() (checa sys.frozen, atributo que o PyInstaller injeta em runtime) e, quando verdadeiro, save_dir() retorna Path(sys.executable).resolve().parent em vez do caminho baseado em __file__; Android (is_android()) e desktop rodando de fonte (uv run main.py) continuam com o comportamento anterior. Ver design seção 23. (R4.5) Decisão de design confirmada com o dono do projeto: gravar ao lado do executável (não em um diretório padrão de dados do SO como %APPDATA%), já que a distribuição é um zip/tar portátil (R13.2) e não uma instalação em local somente-leitura como Program Files. Validado: tests/test_storage.py::test_save_dir_frozen_desktop_uses_executable_dir (simula sys.frozen/sys.executable via monkeypatch); suíte completa (70 testes) verde. Build real via uv run pyinstaller BlockyBee.spec e verificação manual em dist/BlockyBee.exe: com sys.frozen/sys.executable simulados apontando para o executável gerado, storage.save_dir() resolve para dist/, score.save_highscore() cria dist/highscore.json ao lado do .exe e score.load_highscore() recupera o valor salvo corretamente.

  • [x] 34. Conformidade com ty (checagem de tipos) Adicionar ty como dependência de dev (uv add --dev ty), configurar [tool.ty.environment]/[tool.ty.src] em pyproject.toml (python-version = "3.10", exclude = ["p4a-recipes", "specs", ".buildozer", "build", "dist"], ver design seção 28). Rodar uv run ty check . sobre main.py, src/, tests/, scripts/; corrigir violações reais no código, suprimir com # ty: ignore[regra] + comentário curto só quando a causa é uma limitação do checador (módulo só resolvível em runtime de outra plataforma, atributo dinâmico em teste). Adicionar step ty check . em .github/workflows/ci.yml, entre ruff format --check e pytest. Confirmar uv run pytest e ruff check/ruff format --check continuam verdes. (R20) p4a-recipes/ excluído do escopo: diferente do ruff (task 31, que inclui as receitas por serem código próprio do projeto), ty precisa resolver imports de verdade — e as receitas importam sh/pythonforandroid.*, pacotes que só existem dentro da imagem Docker do buildozer (R17), nunca no .venv local. Incluí-las geraria só unresolved-import permanente e não-acionável. Violações reais encontradas (3, todas corrigidas no código, nenhuma suprimida): src/biome.py (_lerp_color) e src/particles.py (ParticleSystem.burst) construíam uma cor RGB a partir de expressão de tamanho variável (genexpr / slice de pygame.Color) e atribuíam a tuple[int, int, int] — ty infere tuple[int, ...] para as duas formas; corrigido desempacotando em variáveis nomeadas e retornando/atribuindo um literal de 3-tupla. src/input.py (InputManager) anotava dict[int, pygame.joystick.Joystick], mas o próprio stub do pygame-ce documenta Joystick como função-fábrica (não classe) nesta versão da lib; corrigido usando pygame.joystick.JoystickType (o tipo real da instância) e removida a chamada redundante joystick.init() (deprecated desde 2.0.0, a construção já inicializa). Supressões pontuais (2, com comentário explicando o motivo): src/storage.py — from android.storage import app_storage_path dentro do try/except ImportError (seção 23), módulo só existe em runtime p4a; # ty: ignore[unresolved-import]. tests/test_storage.py — atribuição dinâmica de atributos a um fake types.ModuleType para simular o módulo android.storage injetado pelo p4a nos testes; # ty: ignore[unresolved-attribute]. Validado: uv run ty check . reporta zero diagnósticos; suíte completa (70 testes) e ruff check/ruff format --check permanecem verdes.

Tarefas adicionais da v2 (pós-Android) — Ícone do aplicativo

Pedido do dono do projeto: o ícone do app deve ser a personagem do jogo (a abelha), e no Android o ícone precisa aparecer "com as proporções ajustadas" — sem cortar a personagem quando o launcher aplica sua máscara (círculo/squircle/quadrado arredondado). Ver design.md seção 29.

  • [x] 36. Geração do ícone por código e integração no desktop Criar scripts/generate_app_icon.py (script standalone, reaproveitando textures.make_bee): gera assets/app_icon_512.png (janela + fonte do .ico), assets/app_icon.ico (multi-resolução 16–256px, construído via struct da stdlib embutindo PNGs, sem depender de Pillow) e assets/android_icon_legacy.png. Criar src/assets.py::asset_path() (resolve sys._MEIPASS quando storage.is_frozen(), raiz do projeto caso contrário) e chamar pygame.display.set_icon() em Game.__init__ (src/game.py), envolvido em try/except para degradação graciosa. Atualizar BlockyBee.spec: datas=[('assets/app_icon_512.png', 'assets')] no Analysis e icon='assets/app_icon.ico' no EXE. Testes para as funções puras do script (ajuste de escala, construção do .ico) e para asset_path() nos dois ramos. Validar rodando uv run main.py (ícone na barra de título) e gerando o executável (uv run pyinstaller BlockyBee.spec, ícone no .exe/Explorer). (R21.1, R21.2, R21.3, R21.7) Implementado conforme o design (seção 29.1–29.3), sem desvios. make_composed_icon() desenha uma cena com céu do Overworld + faixa de grama/terra + abelha centralizada, reamostrada em escala nearest-neighbor (pixel-art fiel, R7.1); só o .ico usa smoothscale nos tamanhos pequenos (16–48px), documentado no design como o único ponto onde a suavização é aceitável. build_ico() monta o container ICONDIR/ICONDIRENTRY manualmente com struct, embutindo um PNG por resolução (16/32/48/64/128/256) — validado por round-trip (pygame.image.load de cada entrada extraída de volta) tanto no teste automatizado quanto por file assets/app_icon.ico (reconhecido como "MS Windows icon resource" com 6 ícones). src/assets.py::asset_path(): replica o padrão já usado por storage.py (seção 23), mas na direção oposta — sys._MEIPASS é onde o PyInstaller onefile extrai dados para leitura (correto aqui), ao contrário de storage.save_dir(), que evita _MEIPASS de propósito por ser efêmero (não serve para gravar o recorde, task 33). Game.__init__ chama pygame.display.set_icon() envolvido em contextlib.suppress(OSError, pygame.error) (troca de um try/except: pass por sugestão do ruff/SIM105) antes do set_mode, sem custo perceptível no Android (onde não há efeito visível, mas também não há necessidade de um if is_android() para pular — asset_path() já resolve para a raiz do projeto lá, e o arquivo existe no APK via source.include_exts = py,png). Validado: suíte completa (87 testes = 81 anteriores + 6 novos, tests/test_assets.py e tests/test_generate_app_icon.py) verde; ruff check/ruff format --check/ty check sem violações. Smoke test real (não headless) de uv run main.py por alguns segundos sem exceções. Build real via uv run pyinstaller BlockyBee.spec: log confirma "Copying icon to EXE"; dist/BlockyBee.exe executado por alguns segundos sem exceções. Não verificado visualmente neste ambiente: confirmar a olho que o ícone da abelha aparece de fato na barra de título/taskbar e no Explorer (o smoke test só confirma ausência de erro, não a aparência) — recomenda-se uma checagem visual rápida pelo dono do projeto antes de publicar.

  • [x] 37. Ícone adaptativo do Android no buildozer.spec Estender scripts/generate_app_icon.py para também gerar assets/android_icon_foreground.png (432×432, só a abelha, escalada para caber nos 66/108 dp da zona segura de máscara) e assets/android_icon_background.png (432×432, gradiente de céu do Overworld, opaco, sem a abelha). Adicionar ao buildozer.spec: icon.filename (aponta para o ícone legado da task 36, cobre API < 26), icon.adaptive_foreground.filename e icon.adaptive_background.filename (cobrem API ≥ 26, R21.4–R21.6). Validar buildozer.spec como INI bem formado e as dimensões/canal alfa dos PNGs gerados; se Docker estiver acessível neste ambiente (ver design seção 25), rodar o build real do APK e confirmar BUILD SUCCESSFUL com as novas chaves. (R21.4, R21.5, R21.6, R21.7) As três camadas já foram geradas na task 36 (mesmo scripts/generate_app_icon.py, seção 29.1 do design) — esta task só adiciona as três chaves correspondentes no [app] do buildozer.spec, seguindo o estilo de caminho relativo já usado no arquivo (sem %(source.dir)s, já que source.dir = . torna as duas formas equivalentes aqui). Validado como INI bem formado via configparser (mesma checagem das tasks 27/31). Docker estava acessível neste ambiente (imagem kivy/buildozer:latest já em cache local, Buildozer 1.6.1.dev0) e o build real foi executado ponta a ponta, reaproveitando o cache de SDK/NDK persistido de uma sessão anterior (~/.buildozer-android-global-cache, ~2.6 GB) e o .buildozer/ do projeto — BUILD SUCCESSFUL in 1m 36s, gerando bin/blockybee-0.2.0-armeabi-v7a_arm64-v8a_x86_64-debug.apk (62 MB). 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. Verificação real do conteúdo do APK (além do log de build), inspecionando o .apk extraído (é um zip): 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, não um ícone comum); res/mipmap/icon_foreground.png (432×432, RGBA — canal alfa presente, confirmando a camada transparente) e res/mipmap/icon_background.png (432×432, RGB — sem alfa, confirmando a camada opaca) batem em dimensão e em tamanho de arquivo em bytes com os PNGs gerados por scripts/generate_app_icon.py (1277 e 1488 bytes respectivamente) — prova de que são exatamente os arquivos gerados, não um fallback ou um ícone padrão do template. res/mipmap/icon.png (2292 bytes) também bate com android_icon_legacy.png, confirmando o ícone legado (API < 26). Ainda não verificável neste ambiente: 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 verificado aqui é que os recursos corretos (as camadas certas, nos tamanhos certos, com/sem alfa conforme esperado) chegam ao APK; a etapa que falta é inteiramente do lado do sistema operacional Android no aparelho, fora do alcance deste ambiente (mesma limitação da seção 25). Recomenda-se instalar o APK gerado (ou o de uma Release futura) num celular e confirmar visualmente que a abelha aparece inteira, sem antenas/asas cortadas, em qualquer forma de ícone que o launcher use.

Tarefas da v3 — Identidade, tela, GPU e rigor SDD (pendentes)

Bloco A — Identidade

  • [x] 42. Rename para Blocky Bee Trocar o nome do produto em todos os pontos fora de specs/v1 e specs/v2 (que não são reescritos, R22.4): src/config.py (TITLE), src/ui.py (texto da tela inicial), buildozer.spec (title e package.name = blockybee), BlockyBird.spec → BlockyBee.spec (inclusive o name= interno), .github/workflows/release.yml (nomes de artefato e comando do PyInstaller), README.md, CLAUDE.md, e as strings de exemplo em src/storage.py / tests/test_storage.py. package.domain continua com.douglaspands. Validar que a suíte continua verde, que o buildozer.spec segue sendo INI bem formado (mesma checagem via configparser das tasks 27/31/37) e que uv run main.py abre com o título novo. (R22.1, R22.2, R22.4) Implementado conforme o design (seção 31), sem desvios. 8 arquivos tocados fora de specs/: src/config.py (TITLE), src/ui.py ("BLOCKY BEE" na tela inicial), buildozer.spec (title e package.name), BlockyBee.spec (renomeado com git mv, preservando o histórico do arquivo, mais o name='BlockyBee' interno), .github/workflows/release.yml (7 ocorrências: comando do PyInstaller, empacotamento Windows/Linux e os 3 nomes de asset), README.md, CLAUDE.md, e as strings de exemplo em src/storage.py e tests/test_storage.py (incluindo os caminhos fictícios /data/data/org.blockybee/...). Não precisou mudar: o passo "Rename APK sem comprimir" do release.yml localiza o artefato por find bin -name '*.apk', sem nome fixo, então segue funcionando com o package.name novo. package.domain continua com.douglaspands — o identificador completo passa de com.douglaspands.blockybird para com.douglaspands.blockybee. specs/v1/ e specs/v2/ ficaram intactas (R22.4): continuam dizendo "Blocky Bird", que era o nome correto quando foram escritas. Só specs/v3/ foi atualizada, junto com a linha da v3 na tabela de specs/README.md. Validado: suíte completa (86 testes) verde; ruff check, ruff format --check e ty check sem violações; buildozer.spec validado como INI bem formado via configparser, reportando title = Blocky Bee e package.name = blockybee. Não verificado neste ambiente: o título novo na barra da janela e o nome do .exe gerado pelo PyInstaller dependem de execução gráfica e de build de release — ficam cobertos pela task 70 (README/checklists) e pelo checklist manual.

Bloco B — Instrumentação (medir antes de otimizar)

  • [x] 43. src/perf.py e sobreposição de diagnóstico Implementar FrameProfiler com médias móveis de tempo de update, tempo de draw e taxa de quadros, e uma sobreposição que os desenha com o pixelfont já existente, junto do backend de render em uso (task 49). Ativada apenas por BLOCKY_PERF=1; quando desligada, o profiler não é instanciado e a medição não entra no caminho quente. Testes para o cálculo das médias e para a ativação por variável de ambiente (ligada/desligada). (R30.1, R30.2, R26.5) Implementado conforme o design (seção 39), sem desvios. _MovingAverage usa buffer circular pré-alocado com soma corrente — adicionar uma amostra é uma escrita, duas somas e um incremento de índice, sem alocar nada. A implementação ingênua (lista que cresce + sum() na leitura) alocaria por frame, que é justamente o que a v3 combate; seria contraditório instrumentar desempenho com um instrumento que piora o desempenho. FrameProfiler usa __slots__ pelo mesmo motivo. API de cronometragem: begin() marca o início e cada end_* mede desde a última marcação e remarca o cronômetro, então um frame inteiro custa três chamadas (begin → end_update → end_draw) em vez de quatro, sem intervalo perdido entre as etapas. Custo quando desligada (R30.2): Game.__init__ só instancia o profiler se perf.enabled(), e Game.run iça self.profiler para uma variável local antes do laço, ramificando uma vez por frame entre um caminho instrumentado e um caminho limpo — no caminho limpo não há nem chamada de medição nem acesso a atributo. Ajuste necessário fora do previsto: pixelfont.GLYPHS não tinha o glifo ., e as linhas da sobreposição exibem milissegundos com uma casa decimal (1.2MS). Sem o glifo, o caractere cairia no _BLANK e sairia como espaço (1 2MS) — silenciosamente errado. Foi adicionado um ponto 5×7 (pixel único na última linha), e test_lines_usa_apenas_glifos_existentes passou a varrer cada caractere de cada linha contra GLYPHS, para que uma linha futura com caractere sem glifo quebre o build em vez de sair truncada na tela. Escala 2 na sobreposição: com escala 1 os glifos ficam com 5×7 px lógicos, o que num celular de 1080 px de largura vira ~11 px de altura física — legível no limite. A escala 2 dobra isso sem competir por espaço com o jogo. Testes (13 novos, tests/test_perf.py): média móvel vazia/parcial/com janela cheia (a amostra mais antiga é de fato expulsa); FPS derivado da duração real do frame e zero sem amostras; enabled() tratando ausência, "" e "0" como desligado e qualquer outro valor como ligado; cobertura de glifos; a sobreposição desenha no canto superior esquerdo preservando a margem e sem tocar o centro da tela; e Game sem profiler por padrão / com profiler quando ligado. Validado: suíte completa (99 testes) verde; ruff check, ruff format --check e ty check sem violações.

  • [x] 44. scripts/benchmark.py e baseline da v2 Script headless (SDL_VIDEODRIVER=dummy) que roda N frames de cada cenário — PRONTO, JOGANDO nos três biomas com colunas em tela, e GAME_OVER com partículas — e reporta ms/frame p50 e p95, contagem de draw calls e alocação de memória. Executar antes de qualquer otimização e gravar os números no bloco BASELINE da seção 30 do design.md. Teste para as funções puras de estatística (percentis) do script. (R30.3, R30.4, R30.5) Implementado conforme o design (seção 39), com dois desvios que a própria medição obrigou — ambos descobertos rodando o script, não previstos no papel: Desvio 1 — a métrica de memória do plano media a coisa errada. O design pedia sys.getallocatedblocks() e gc.get_stats(). Medidos, os dois deram zero em todos os cenários, e por um motivo estrutural, não por bug: getallocatedblocks() reporta o saldo líquido de blocos, então um frame que aloca e libera 40 objetos aparece como zero — ele detecta vazamento, não pressão; e o contador de coletas do gc só avança quando há excedente líquido de objetos-contentores, que um laço equilibrado nunca produz. A métrica foi trocada por KB transitórios por frame, medidos com tracemalloc.reset_peak() no início do frame e o pico lido no fim — a marca d'água do que foi alocado e descartado dentro daquele frame. Roda numa passada separada, porque o tracemalloc deixa o processo várias vezes mais lento e contaminaria a medição de tempo. Desvio 2 — artefato de medição que inflava os cenários posteriores. Na primeira execução os tempos cresciam monotonicamente ao longo da tabela (3,3 → 3,4 → 12,4 → 13,0 → 14,9 ms), o que não corresponde a nenhuma diferença real entre biomas. A causa é a mesma limitação que o conftest.py já documenta desde a v2 (design seção 20.2): pygame.SCALED exige um renderizador SDL e o driver dummy só permite um por processo, então cada Game() novo deixava um renderizador para trás. _fresh_display() (display.quit() + display.init() entre cenários) eliminou a deriva — os cinco cenários passaram a ficar na faixa de 3,3–4,1 ms, como esperado. Contagem de draw calls: _CountingSurface é uma subclasse real de pygame.Surface, não um proxy, porque pygame.draw.* exige uma superfície de verdade como primeiro argumento; um objeto que apenas delegasse quebraria essas chamadas. As funções de pygame.draw usadas pelo jogo são envolvidas separadamente, já que não passam pelo blit. _NoCollisionGame é uma subclasse que sobrescreve _collision_texture, em vez de um monkeypatch do método — sem isso o ty acusa invalid-assignment, e a subclasse mantém a assinatura sob checagem de tipos em vez de silenciar o diagnóstico. Baseline registrado na seção 30 do design.md, com o ambiente de medição, a leitura dos números (por que ~3,4 ms num desktop x86 ainda significa problema num ARM de celular) e o limite conhecido da métrica: tracemalloc só enxerga o alocador do Python, então a Surface de ~1,4 MB que _dim_overlay cria por frame não aparece na coluna de KB — ela se manifesta no p50/p95 do GAME_OVER, que é de fato o cenário mais lento da tabela. Testes (7 novos, tests/test_benchmark.py): percentil com lista vazia, amostra única, mediana, interpolação entre vizinhos, preservação da lista de entrada, e formatação da linha markdown. O caso do p95 mereceu correção durante a escrita: [1.0]*99 + [100.0] não faz o p95 subir (com 100 amostras ele cai no índice 94–95, ainda em 1.0) — o teste passou a usar 10% de amostras ruins, que é o que de fato caracteriza o engasgo periódico que o p95 existe para expor. A execução completa do benchmark não é testada de propósito: roda centenas de frames de cinco cenários e levaria mais que a suíte inteira. Validado: suíte completa (106 testes) verde; ruff check, ruff format --check e ty check sem violações; benchmark executado ponta a ponta em 300 frames por cenário.

Bloco C — Canvas, tela cheia e faixas decorativas

  • [x] 45. src/viewport.py — canvas lógico e área jogável Implementar o cálculo da seção 32.3: PLAY_W/PLAY_H como constantes de mundo, canvas lógico com a proporção real da tela/janela, play centralizado na horizontal, e as bandas (sky, ground, left, right) derivadas. set_mode passa a FULLSCREEN no Android e 960×720 | RESIZABLE no desktop. config.SCREEN_W/SCREEN_H deixam de ser constantes de módulo e passam a ser resolvidos pelo Viewport ativo, no mesmo padrão de config.ground_y(). Variável dev-only BLOCKY_CANVAS=LxA para forçar um canvas no desktop. Testes de tabela cobrindo as cinco proporções da seção 32.3 (celular 20:9 e 16:9, desktop 960×720, monitor 16:9, exatamente 2:3), mais os invariantes: a área jogável é sempre 480×720, o canvas nunca é menor que ela, e a soma das bandas fecha exatamente com o canvas (sem pixel perdido em largura ímpar). (R23.1, R23.2, R23.3, R23.4, R23.7)

Implementado conforme a seção 32.3, com dois ajustes de forma.

Ajuste 1 — pygame.SCALED mantido como escalador provisório. A seção 32.7 mostra o set_mode sem SCALED, porque na v3 quem leva o canvas lógico para a tela real é Renderer.logical_size (seção 33.2). Só que o renderizador chega na task 49: tirar o SCALED agora deixaria o Android desenhando um canvas de 480×1067 no canto de uma tela de 1080×2400, sem escala nenhuma — uma regressão viva entre as tasks 45 e 49. Com SCALED | FULLSCREEN, R23.1 e R23.3 já valem no aparelho hoje, e a escala é uniforme porque o canvas tem exatamente a proporção da tela. Bônus: input.py continua recebendo coordenada lógica do SDL, sem precisar antecipar a task 51. viewport.display_flags() documenta que o SCALED sai quando o renderizador entrar.

Ajuste 2 — funções, não variáveis de módulo. O design escreve config.SCREEN_W/SCREEN_H "resolvidos pelo Viewport ativo"; a implementação usa config.screen_w()/screen_h(). Uma variável de módulo reatribuída não resolveria o problema real: seis módulos faziam from src.config import SCREEN_W, o que congela o valor no import — antes de o display existir. A forma de função é a única que garante leitura do viewport corrente, e é literalmente o padrão de config.ground_y() que o design cita. Pelo mesmo motivo ui.MAX_TEXT_W e ui.MUTE_ICON_RECT viraram ui.max_text_w() e ui.mute_icon_rect(). No Ground.draw os limites dos dois laços de blit são içados para locais, para não resolver o viewport por iteração.

Observado: com o canvas de 960×720 no desktop, o benchmark.py sobe de 3,3–4,1 ms de p50 (baseline da v2, medido a 480×720) para 6,2–11,4 ms, e de ~96 para 110–126 draw calls por frame — chão e parallax passam a cobrir o dobro de largura, tudo ainda em CPU. É a conta esperada, e é exatamente a que o caminho de GPU (bloco D) e o atlas (bloco E) existem para pagar; os números finais entram na task 73.

Testes (41 novos, tests/test_viewport.py): a tabela das cinco proporções da seção 32.3, mais os invariantes por proporção (área jogável sempre 480×720, canvas nunca menor que ela, proporção do canvas igual à da tela, bandas fechando exatamente com o canvas nos dois eixos), o pixel ímpar indo para a banda direita, a sobra vertical enchendo o chão antes do céu, o BLOCKY_CANVAS com valores válidos e malformados, e a idempotência de compute sobre a própria saída — que é o que sustenta forçar o canvas pelo tamanho de tela. Em tests/test_game.py, o teste de resolução fixa da v2 deu lugar a dois: o display criado com o canvas calculado e o caminho Android (tela cheia, canvas 480×1067 a partir de 1080×2400, área jogável intacta). conftest.py ganhou _reset_viewport, porque o Game define o viewport globalmente e ele vazaria de um teste para o outro.

Validado: suíte completa (148 testes) verde; ruff check, ruff format --check e ty check sem violações. Conferência visual por frame renderizado em 960×720 e em BLOCKY_CANVAS=480x1067: a cena preenche o canvas inteiro nos dois casos, sem barra preta em lado nenhum. Como esperado nesta task, a área jogável ainda não está isolada dentro do canvas — abelha, chão e colunas seguem ancorados no canvas, e é a task 46 (faixas verticais) e a 47 (faixas laterais) que os movem para play.

Não verificável neste ambiente: tela cheia na resolução nativa em aparelho real (R23.3) — coberto pela task 72.

  • [x] 46. Faixas vertical (céu estendido e chão mais fundo) Deslocar o jogo para coordenadas de canvas conforme a seção 32.4: posição inicial da abelha, teto (bird.py), ground_y(), faixa de sorteio de gap_y e descarte de colunas passam a usar play. Nenhuma constante de física ou de bioma muda. O HUD de pontuação e o ícone de mudo migram para a faixa de céu quando ela existe. Testes: com um canvas alongado, a abelha não sobe acima de play.top, o gap_y sorteado fica sempre dentro da área jogável, e o HUD é posicionado na faixa de céu; com canvas 2:3, tudo cai exatamente onde caía na v2 (teste de não-regressão). (R23.4, R24.1, R24.2, R24.5, R25.1, R25.7)

Implementado conforme a tabela da seção 32.4. config.play() entrou como acessador da área jogável e ground_y() passou a ser play.bottom - GROUND_H — daí a faixa de chão sai de graça, porque Ground.draw já enchia até a base do canvas. Nenhuma constante de física ou de bioma foi tocada.

Ajuste 1 — o sorteio de gap_y precisou de piso próprio. A tabela diz "mesma fórmula, com o novo ground_y()", mas GAP_MARGIN sozinho é um número absoluto de canvas: com faixa de céu de 251px, uniform(80, ...) sortearia o centro da abertura dentro da decoração, acima do teto da abelha e portanto impossível de atravessar. O piso passou a ser play.top + GAP_MARGIN.

Ajuste 2 — telas de estado e banner de bioma também migraram para play. A task nomeia só o HUD e o ícone de mudo, mas PRONTO, PAUSADO, GAME_OVER e o banner estavam ancorados em screen_h(). Num canvas de 480×2800 o screen_h() // 4 da tela inicial cai dentro da faixa de céu, acima da área jogável — o texto sairia do jogo. Todos passaram a play.centerx / play.top, e num canvas 2:3 os números são idênticos aos da v2. Pelo mesmo motivo ui.max_text_w() passou a medir contra a área jogável: contra o canvas, um texto longo teria licença para escorrer por cima das faixas laterais.

Ajuste 3 — decor.py deixou de derivar a linha do chão da altura da surface. Colinas, poças de lava e pilares se ancoravam em surface.get_height() - GROUND_H, o que na v3 é a base da faixa decorativa: eles afundariam 96px na terra. Agora usam config.ground_y().

Decisão — o ícone de mudo fica no canto do canvas, não no da área jogável. É o ponto mais alcançável no celular, e R25.6 permite explicitamente um controle de interface sobre faixa decorativa. Verticalmente ele mora na faixa de céu quando ela o comporta (R25.7); num celular 16:9, cuja faixa tem só 37px, desce inteiro para a área jogável em vez de ficar metade em cada uma. A pontuação segue a mesma regra, centrada na faixa quando cabe.

Mantido de propósito: as colunas continuam sendo desenhadas a partir do topo do canvas, atravessando a faixa de céu como se viessem de fora da tela (seção 32.5). A colisão não muda, porque a abelha nunca passa de play.top.

Testes (16 novos, tests/test_bands.py e tests/test_game.py): com o canvas do celular 20:9, o teto do voo é play.top e não o topo do canvas; a trajetória de queda medida a partir de play.top é idêntica nas duas proporções (velocidades batem bit a bit, alturas a menos de epsilon de float — somar o mesmo delta a 200 ou a 451 arredonda diferente, e isso é representação, não física); toda abertura sorteada em 200 frames fica dentro da área jogável, bordas incluídas; a coluna é descartada em play.left; o chão enche a faixa decorativa até a base do canvas, verificado por pixel; a pontuação e o ícone de mudo vão para a faixa de céu, caem de volta na área de jogo quando a faixa é curta demais, e num canvas 2:3 ficam exatamente onde ficavam na v2; a abelha nasce no meio da área jogável no caminho Android. O teste de decoração da v2 que afirmava sobre a altura da surface foi reescrito para afirmar sobre a linha do chão da área jogável.

Validado: suíte completa (164 testes) verde; ruff check, ruff format --check e ty check sem violações. Conferência visual em BLOCKY_CANVAS=480x1067, em PRONTO e em JOGANDO: céu vazio acima da área jogável, abelha e textos dentro dela, colunas atravessando a faixa de céu, colinas assentadas na linha do chão e terra funda até a base da tela.

  • [x] 47. Faixas laterais — corte transversal do terreno Gerar por bioma uma faixa lateral opaca com camadas de bloco empilhadas e veios de minério, reaproveitando as texturas da seção 10 e os desenhadores de decor.py, com a linha do chão alinhada a ground_y(). Desenhá-las depois das colunas, para ocultar o que ainda não entrou na área jogável. PipeManager._spawn passa a criar em play.right. Céu, parallax e chão passam a cobrir a largura toda do canvas. Testes: a coluna nasce em play.right; nenhum pixel de coluna é visível fora de play (verificado pela ordem de desenho registrada no FakeRenderer); a faixa é opaca; o tempo entre o nascimento da coluna e a chegada à abelha é igual num canvas 960×720 e num 480×720. (R24.3, R24.4, R25.2, R25.5)

Implementado em src/bands.py, desenhado depois das colunas, da abelha e das partículas, na posição que a seção 32.6 reserva. Céu, parallax e chão já cobriam a largura toda do canvas desde a v2 (_tile e Ground.draw usam a largura real da surface), então R25.5 não precisou de código novo — ganhou teste.

Ajuste 1 — a parede precisou de duas âncoras, não uma. A seção 32.5 pede as duas coisas ao mesmo tempo: camadas empilhadas (grama → terra → pedra) e a linha do chão alinhada a ground_y(). Ancorar tudo em ground_y não entrega a primeira: abaixo da linha do chão cabem no máximo 4 fileiras de bloco (GROUND_H + MAX_GROUND_EXTRA = 192px), então a camada profunda nunca aparecia — a parede virava "grama → terra" e ponto. A implementação usa duas âncoras: a crosta (borda + solo) no topo do canvas, marcando a superfície lá em cima, com a camada profunda no miolo, que é onde os veios de minério aparecem; e uma segunda fileira de borda exatamente em ground_y(), com solo logo abaixo, que é o que faz a terra parecer contínua de uma borda à outra. As duas âncoras não compartilham grade — ground_y depende da faixa de céu e quase nunca é múltiplo de 48 —, então a faixa é preenchida com a camada profunda primeiro e as fileiras são desenhadas por cima.

Ajuste 2 — o teste de oclusão não usa FakeRenderer. Ele só nasce na task 50, junto do renderizador. Aqui a verificação é por pixel, e o teste tem contraprova: na segunda metade ele desliga a faixa e exige que aí sim a coluna apareça. Sem isso, o teste passaria mesmo que a coluna jamais tivesse sido desenhada naquela região, e não provaria nada sobre a oclusão.

Decisões menores. DEEP_BLOCK (Overworld → pedra, Cave → pedregulho, Nether → obsidiana) mora em bands.py e é chaveado pelo id do bioma, no mesmo padrão de decor._LAYERS, para decoração não virar campo de Biome. decor._draw_ore_veins passou a ser público (draw_ore_veins), já que agora tem dois consumidores. As duas paredes são construídas com sementes diferentes: iguais, entregariam uma simetria de espelho que denunciaria a repetição. As superfícies são cacheadas por bioma e invalidadas quando o canvas ou ground_y mudam — a mesma disciplina de BiomeManager._ensure_gradients.

Testes (9 novos, tests/test_bands.py): a coluna nasce em play.right; o número de frames entre o nascimento e a chegada à abelha é idêntico num canvas 1280×720 e num 480×720; a faixa é opaca em toda a altura, nos três biomas, verificado contra um fundo magenta que denunciaria qualquer buraco; numa tela 2:3 nada é desenhado; a fileira de borda da faixa bate pixel a pixel com a textura de borda na altura de ground_y(); a coluna que ainda não entrou na área jogável não sobrevive ao desenho da faixa, com a contraprova descrita acima; e céu, parallax e chão chegam às duas bordas do canvas.

Validado: suíte completa (173 testes) verde; ruff check, ruff format --check e ty check sem violações. Conferência visual no canvas 960×720 nos três biomas, mais uma verificação ponto a ponto do material na emenda: em Overworld, Cave e Nether o bloco da faixa e o do chão da área jogável são o mesmo em toda a altura da junção (variam só no ruído interno da textura, que é por construção).

  • [x] 48. Retrato travado e recomputação em redimensionamento Definir SDL_HINT_ORIENTATIONS=Portrait antes do pygame.init(), reforçando no lado do SDL o orientation = portrait que o buildozer.spec já declara. No desktop, tratar WINDOWRESIZED recalculando o Viewport e reconstruindo o atlas. Verificar no APK construído (task 72) que o AndroidManifest.xml traz android:screenOrientation="portrait", com a mesma disciplina de inspeção do .apk usada na task 37. Testes: um evento de redimensionamento sintético produz um Viewport novo e coerente, e a área jogável permanece 480×720 depois dele. (R23.5, R23.6)

Ajuste 1 — o nome do hint no plano não é o nome que o SDL lê. SDL_HINT_ORIENTATIONS é o nome da macro em C; a string que ela contém, e que SDL_GetHint procura no ambiente, é SDL_IOS_ORIENTATIONS (#define SDL_HINT_ORIENTATIONS "SDL_IOS_ORIENTATIONS", conferido no cabeçalho do SDL2). O prefixo IOS é histórico — a própria documentação do SDL descreve o hint como "which orientations are allowed on iOS/Android". Exportar o nome da macro não teria efeito nenhum, e o silêncio seria total. Não é verificável localmente: a build do SDL2 para Windows nem contém essa string, porque quem a lê são os backends de Android e iOS.

Ajuste 2 — "reconstruindo o atlas" ainda não se aplica. O atlas nasce no bloco E. Hoje os caches que dependem do canvas — faixas laterais e gradientes de bioma — são chaveados pelo tamanho, então se invalidam sozinhos quando o canvas muda; não houve o que invalidar à mão.

Descoberto na implementação — trocar o canvas do SCALED exige recriar o display, e recriar tem armadilha. Medido: o terceiro set_mode(SCALED) de um processo aborta o interpretador no pygame-ce 2.5.7, sem exceção para capturar, tanto no driver windows quanto no dummy. Com display.quit() + display.init() antes de cada recriação, seis ciclos seguidos funcionam — é a mesma limitação de um renderizador SDL por processo que o conftest.py documenta desde a v2 (design seção 20.2). Quando a camada de render entrar (task 49), isto vira renderer.logical_size = viewport.canvas e o rodeio inteiro desaparece.

Descoberto na implementação — recriar o display encolhe a janela. set_mode(canvas) cria a janela do tamanho do canvas, então arrastar para 1920×1080 faria a janela pular para os 1280×720 do canvas: o redimensionamento pareceria quebrado. viewport.restore_window_size() devolve o tamanho arrastado via pygame.Window.from_display_module() — a mesma ponte que o design já prevê para a task 49. Com SCALED, tamanho de janela e tamanho de canvas são independentes, e é justamente isso que se está usando.

Debounce. Cada pixel de um arrasto emite um WINDOWRESIZED; aplicar todos recriaria o display dezenas de vezes por segundo. O novo canvas só é aplicado depois de RESIZE_SETTLE_FRAMES (12, ~200ms a 60 FPS) sem evento novo, e um evento durante a espera reinicia a contagem — só o tamanho final chega ao set_mode.

Limite conhecido: numa janela mais baixa que os 720px da área jogável, o SDL não permite que ela fique menor que o canvas lógico, e a altura para em 720. É coerente com o canvas nunca encolher abaixo da área jogável (R23.4), então foi mantido.

Testes (8 novos, tests/test_resize.py): o hint de retrato é definido e não sobrescreve um valor já presente no ambiente (investigar paisagem não deve exigir editar código); um WINDOWRESIZED sintético vira ação de redimensionamento; aplicar 1920×1080 produz canvas 1280×720 com a área jogável intacta e o config enxergando o novo canvas; aplicar 1080×2400 recalcula as faixas, não só o tamanho; uma janela maior na mesma proporção não recria o display; e o debounce só aplica depois que o arrasto assenta, com o reinício da contagem a cada evento novo.

Validado: suíte completa (181 testes) verde; ruff check, ruff format --check e ty check sem violações. Exercitado também no driver real do Windows, fora do dummy: quatro redimensionamentos consecutivos (1920×1080, 1000×1400, 1400×700, 960×720) recalculam canvas e faixas, mantêm a área jogável em 480×720 e preservam o tamanho da janela, sem travar.

Não verificável neste ambiente: a trava de orientação em aparelho real e o android:screenOrientation="portrait" no AndroidManifest.xml do APK — task 72.

Bloco D — Render acelerado por GPU

  • [x] 49. src/render.py — interface de render e cascata de compatibilidade Implementar a interface da seção 33.1 (make_image, clear, draw, fill, present, to_logical, size, backend) com duas implementações: GpuRenderer sobre pygame._sdl2.video (Window.from_display_module(), Renderer, logical_size, Texture.from_surface, draw_color/fill_rect, Texture.alpha, coordinates_from_window) e SurfaceRenderer sobre o caminho da v2. Cascata de três níveis (accelerated=1, vsync=True → accelerated=-1 → superfície), com log do motivo de cada queda. Testes: a cascata cai de nível quando a criação falha (via monkeypatch) e nunca levanta exceção para quem chama; backend reflete o nível efetivo; as duas implementações produzem o mesmo resultado observável para a mesma sequência de chamadas. (R26.1, R26.2, R26.3, R26.4, R26.5, R26.6, R26.7)

Implementado em src/render.py, com Image/Renderer como classes base finas, GpuImage/GpuRenderer, SurfaceImage/SurfaceRenderer e a fábrica create(canvas, window, fullscreen=, title=) que desce a cascata. Nenhum módulo de jogo foi ligado a ela ainda — a migração é a task 50, e game.py segue no caminho da v2 até lá.

Descoberto na implementação — Window.from_display_module() não serve para criar o renderizador. É a premissa da seção 33.2 do design, e ela é falsa: depois de pygame.display.set_mode(...) a janela já tem uma Surface associada, e SDL_CreateRenderer recusa com pygame.error: Surface already associated with window. Medido nas três combinações (accelerated=1, -1, padrão) e nos dois drivers (windows e dummy) — não é peculiaridade de ambiente headless. O caminho de GPU passa então a criar a própria janela com pygame._sdl2.video.Window(title, size=..., fullscreen=..., resizable=...), e nesse caminho o módulo display deixa de ser o dono da janela: título, ícone e tamanho passam a sair de Window.title / Window.set_icon / Window.size, e present() substitui display.flip(). A fila de eventos não muda, porque eventos do SDL são globais e não pertencem à janela. Verificado ponto a ponto: Window + Renderer funciona nos dois drivers, seis ciclos de criação/destroy() seguidos passam (sem a armadilha do terceiro set_mode(SCALED) da task 48), e set_mode continua funcionando no mesmo processo depois de janelas do _sdl2 existirem — que é o que permite os testes de equivalência abaixo.

Ajuste — o SurfaceRenderer não usa pygame.SCALED. A seção 33.4 o descreve como "pygame.SCALED e Surface.blit, o caminho da v2", mas com SCALED o SDL entrega o mouse já em coordenada lógica e o toque não, e to_logical significaria coisas diferentes conforme o backend ativo — exatamente a assimetria que a task 51 existe para desfazer (R34.1). Aqui 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), então to_logical converte pixel real de janela → canvas nos dois backends, pela mesma conta. O custo do escalonamento extra fica só no nível 3, que é o que só roda quando nenhum renderizador SDL pôde ser criado.

Decisões menores. Image.raw guarda a Texture ou a Surface e é opaco para os módulos de jogo — só o renderizador que criou a imagem o desempacota. draw(image, dest, area=None) aceita Rect (estica) ou uma posição (x, y) (tamanho nativo), e o caminho de superfície escala à mão quando dest traz outro tamanho, porque o blit da GPU estica pelo dstrect e o da Surface não — sem isso os dois backends não desenhariam a mesma coisa. SurfaceRenderer cacheia por tamanho a superfície escalada do present() e a de mistura do fill() com alfa: eram as duas alocações grandes por frame que a seção 30 aponta. make_image do caminho de superfície já chama convert_alpha() com degradação quando não há display — a task 52 completa isso para o caminho de GPU. Foi acrescentada uma oitava operação fora da interface da seção 33.1, snapshot(), exclusiva de diagnóstico e teste (Renderer.to_surface na GPU, cópia do canvas na superfície): é ela que torna verificável a exigência de "mesmo resultado observável". O hint SDL_HINT_RENDER_SCALE_QUALITY=0 (R26.7) foi para cá, porque precisa estar no ambiente antes de o renderizador nascer; SDL_HINT_RENDER_BATCHING continua com a task 76. O log usa o logging da biblioteca padrão, primeiro uso no projeto.

Testes (16 novos, tests/test_render.py): a cascata para no nível 1 quando ele funciona, cai para o nível 2 quando accelerated=1 falha, cai para superfície quando os dois falham, cai para superfície quando pygame._sdl2 não existe na build, e não deixa escapar nem a falha ao abrir a janela; vsync=True é de fato pedido; o motivo de cada queda vai para o log; o hint de escala é definido e não sobrescreve o ambiente; e quatro testes de equivalência que rodam a mesma sequência de chamadas nos dois backends e comparam o resultado — frame completo (limpar, desenhar em tamanho nativo, esticado, recortado e recortado+esticado, e um fill opaco) com igualdade exata de pixel numa amostragem de toda a área do canvas, Image.alpha, fill translúcido e to_logical. Sob SDL_VIDEODRIVER=dummy o nível 1 não existe, então a suíte exercita a primeira queda de nível de verdade, sem simulação.

Validado: suíte completa (197 testes) verde; ruff check, ruff format --check e ty check sem violações. Exercitado também no driver real do Windows, fora do dummy: create() devolve gpu-accelerated, e desenho, fill com alfa (fundo (30,60,90) sob preto a 50% → (15,30,45), exato), to_logical e present() funcionam no renderizador acelerado de verdade.

Fica para a task 50: trocar renderer.logical_size no redimensionamento (o que dispensa o display.quit()/init() da task 48), o ícone da janela pelo Window.set_icon e a instrumentação exibindo backend (R26.5, task 71).

  • [x] 50. Migrar os módulos de desenho para o renderizador bird, pipes, ground, decor, particles, biome e ui deixam de receber pygame.Surface e passam a receber Renderer. Introduzir um FakeRenderer de teste que registra as chamadas, e converter tests/test_ui_layout.py, tests/test_decor.py e a parte afetada de tests/test_game.py para afirmar sobre chamadas em vez de inspecionar pixels. (R26.4)

Feito. Nenhum módulo de desenho recebe mais uma Surface: bird, pipes, ground, decor, particles, biome, bands, ui e também perf.draw_overlay recebem render.Renderer. game.py perdeu o self.screen e passou a self.renderer; draw() termina em renderer.present() no lugar de display.flip().

Renderer.image(key, factory) — a operação que a task 50 acrescentou. A interface da seção 33.1 tinha make_image, que converte uma Surface que já existe. Faltava o passo anterior: quase todo conteúdo do jogo é gerado por código (gradiente de céu, paredes das faixas, glifos de texto, ícone de mudo, formas não retangulares do decor), e cada módulo mantinha um cache próprio de Surface da v2. Ligar isso ao renderizador módulo a módulo replicaria oito caches, cada um sabendo quando invalidar. image(key, factory) centraliza: a fábrica roda uma vez por chave, o resultado vira imagem do backend ativo, e o módulo de desenho não guarda nada nem sabe qual backend está de pé. Os caches próprios de SideBands, BiomeManager e DecorManager sumiram por consequência. forget_images() é o par: no redimensionamento as imagens chaveadas pelo tamanho do canvas nunca mais seriam pedidas e ficariam ocupando memória de vídeo.

Fechados os três pendentes da task 49. O redimensionamento virou renderer.resize(canvas) — logical_size na GPU, canvas fora da tela recriado na superfície —, e com ele o display.quit()/init() da task 48 desapareceu junto com o SCALED; viewport.restore_window_size() e viewport.display_flags() foram removidos por terem ficado sem chamador. O ícone entrou por render.create(..., icon=), porque cada caminho o define de um jeito (Window.set_icon ou display.set_icon) e quem chama não deve precisar saber qual. E o profiler recebe renderer.backend na construção, então a sobreposição de diagnóstico mostra o nível efetivo da cascata (R26.5).

Duas operações a mais no Renderer, pelo mesmo motivo: desde a task 49 é o renderizador que possui a janela, então window_size passou a ser dele. No caminho de superfície ele lê display.get_window_size() e não Surface.get_size() — a superfície de display só acompanha o arrasto no set_mode seguinte, a janela já reflete o tamanho novo.

input.py entrou aqui por necessidade, não por antecipação. Sem self.screen e sem SCALED, InputManager precisava do renderizador para converter coordenada, e _touch_to_logical (que desfazia à mão o letterbox do SCALED só para o toque) perdeu o sentido: mouse e toque agora passam pelo mesmo renderer.to_logical. É a metade de R34.1 que a task 51 fecha; o resto do escopo dela — os testes de toque na faixa lateral e na faixa de céu em PRONTO e em GAME_OVER — continua aberto.

Descoberto na migração — to_surface da GPU não é o canvas lógico. Renderer.to_surface devolve uma superfície do tamanho lógico mas copia os pixels físicos do alvo, sem desfazer a escala de logical_size. snapshot() só é fiel quando canvas e janela têm o mesmo tamanho. Não afeta o jogo (é operação de diagnóstico), mas afeta quem for conferir um canvas de celular no PC: force a janela com BLOCKY_CANVAS ou use o SurfaceRenderer. Documentado na docstring.

Testes (tests/fakes.py, novo). FakeRenderer registra a sequência de chamadas em vez de pintar, e cada FakeImage carrega a chave com que foi pedida — é por ela que o teste reconhece o que foi desenhado (("text", "PAUSADO", 6, cor), ("mute_icon", True), "dirt"). A ordem preservada é o que permite afirmar sobre oclusão. test_ui_layout.py, test_decor.py, test_bands.py, test_biome.py, test_perf.py e a parte afetada de test_game.py deixaram de inspecionar pixels: "o texto de PAUSADO não invade a linha de baixo" substituiu "o pixel (240, 310) é branco". Dois ganhos além da velocidade — as afirmações passaram a ser sobre o que o código pede, e casos que a leitura de pixel não alcançava ficaram verificáveis: que a faixa lateral é desenhada depois da coluna que ela precisa cobrir (R24.4), que o escurecimento é um fill e não uma superfície alocada por frame, que o ícone de mudo é uma chamada de desenho por estado, e que o fade de bioma usa Image.alpha. 208 testes (eram 197), ruff check, ruff format --check e ty check sem violações.

  • [x] 51. Entrada: conversão de coordenadas pelo backend input.py passa a converter mouse e toque pelo mesmo caminho (renderer.to_logical), eliminando o tratamento assimétrico da v2 (que dependia de pygame.SCALED pré-converter o mouse). O recorte de área deixa de ser contra a área jogável: toque ou clique em qualquer ponto do canvas, faixa decorativa inclusive, dispara a ação de voar (seção 32.8) — o hit-test do ícone de mudo é a única exceção, e só coordenada fora do canvas é descartada. Testes: clique e toque no mesmo ponto físico produzem a mesma ação; toque na faixa lateral e na faixa de céu voam; toque no ícone de mudo alterna o mudo em vez de voar; coordenada fora do canvas é ignorada; em PRONTO e em GAME_OVER o toque na faixa inicia e reinicia a partida. (R34.1, R34.2, R34.3, R34.4, R25.6, R15.1, R15.4)

Nenhuma linha de comportamento mudou aqui — e isso é o resultado, não um atalho. A task 50 já tinha entregue as duas metades desta: to_logical unificado para mouse e toque (era consequência obrigatória de largar o SCALED), e o recorte contra o canvas, que veio de graça porque _handle_tap sempre testou contra config.screen_w()/screen_h() — e config passou a resolver isso pelo viewport ativo na task 45, quando canvas e área jogável se separaram. O que a v3 chama de revogação da regra da v2 tinha, portanto, já acontecido, sem que nada afirmasse isso. O que faltava era a verificação, e ela é o entregável da task 51.

O que a verificação encontrou primeiro foi um problema nos próprios testes. tests/test_input.py nunca chamava config.set_viewport, então rodava sobre o viewport global que o arquivo de teste anterior tivesse deixado — na ordem alfabética, o WIDE do fim de test_bands.py. Os testes passavam por coincidência (a geometria era consistente consigo mesma), mas nenhum deles estava exercitando o canvas que sua docstring afirmava. Ativar o viewport explicitamente em _make_input é o que torna possível dizer "toque na faixa lateral" e ter isso significar alguma coisa.

Testes (6 novos, 214 no total). Um parametrizado sobre as quatro faixas — esquerda e direita num canvas 1280x720, céu e chão num canvas 480x1067 — que toca o centro de cada uma, depois de afirmar que a faixa existe naquela tela e que o ponto está fora de config.play() (sem essas duas âncoras o teste passaria por vacuidade numa tela sem faixa). Um para a única exceção: o ícone de mudo dentro da faixa de céu continua alternando o mudo, e não voando (R15.4, R25.6) — é o caso que importa, porque na tela 2:3 que os testes antigos usavam o ícone nem chega a estar numa faixa. E um de ponta a ponta com Game: o clique na faixa lateral entra como evento do SDL e sai como transição de estado, PRONTO → JOGANDO e GAME_OVER → PRONTO com placar zerado (R34.4). O teste de descarte fora do canvas foi renomeado e sua justificativa corrigida — não fala mais em "barra preta de letterbox", que a v3 não tem; ele agora força uma janela incompatível com o canvas de propósito, porque no jogo real o canvas cobre a tela e só o arredondamento da conversão escapa pela borda (R34.3).

Conferido que os testes prendem mesmo a regra: trocando _handle_tap de volta para o recorte da v2 (config.play().collidepoint), os 6 falham e os 8 antigos passam — ou seja, a suíte anterior não tinha nada que impedisse a regra de regredir. Docstrings de _tap e _handle_tap reescritas com o porquê da revogação (design seção 32.8). Suíte completa verde, ruff check, ruff format --check e ty check sem violações.

Bloco E — Atlas pré-renderizado

  • [x] 52. Conversão e envio das texturas base textures.generate_all e o cache de pixelfont.render passam por convert()/convert_alpha() antes de virarem imagem do renderizador, com degradação para a superfície não convertida quando não há display inicializado (condição real sob driver dummy). Testes: as superfícies geradas são convertidas quando há display; a ausência de display não levanta exceção. (R27.2, R27.4)

Feito. render.convert(surface) é a operação única, e ela escolhe o caminho pela bandeira da superfície: convert_alpha() para quem tem alfa por pixel, convert() para o resto. As duas metades importam. convert() numa superfície SRCALPHA apagaria a transparência — as asas da abelha e o vazado dos glifos virariam retângulos opacos; convert_alpha() numa superfície opaca acrescentaria um canal alfa que manda todo blit dela para o caminho de mistura sem ter nada para misturar, que é justamente o custo que a conversão existe para evitar.

SurfaceRenderer.make_image entrou no escopo por consequência. Ele convertia com convert_alpha() incondicional desde a task 49. Converter na origem e depois passar por ali desfaria metade do trabalho: cada textura de bloco, opaca, voltaria a ter alfa. Passou a usar o mesmo convert, e com isso a regra "alfa só para quem tem alfa" vale nos dois pontos. A dupla conversão que sobra (uma superfície que já chegou convertida da origem passa por lá de novo) é barata e de inicialização, e mantém a garantia valendo também para quem constrói a superfície na hora do primeiro image() — gradiente de céu, faixas laterais, ícone de mudo.

Onde a conversão de fato rende — e onde ela não acontece. Medido no driver real do Windows: com o backend gpu-accelerated, pygame.display.get_surface() é None, porque a janela vem do _sdl2 e display.set_mode nunca é chamado. Não há formato de display para converter, convert() levanta pygame.error e a degradação devolve a superfície original — em todo o caminho de GPU. Isso não é uma perda: Texture.from_surface converte no envio, uma vez por textura. R27.4 é, na prática, uma otimização do caminho de superfície, onde a imagem é uma Surface e cada desenho dela é um blit que pagaria conversão de formato pixel a pixel. Está dito na docstring de convert, para que ninguém leia o fallback como uma falha silenciosa.

pixelfont importa convert pelo nome, e não o módulo render. O módulo já expõe uma função pública chamada render — from src import render a sobrescreveria.

Testes (9 novos, 223 no total). O que a verificação encontrou primeiro foi que a afirmação óbvia não serve: sob o driver dummy o formato do display coincide com o formato padrão do pygame (32 bits, mesmas máscaras, mesmas bandeiras), então comparar máscaras não distingue uma superfície convertida de uma que nunca passou pela conversão — o teste passaria com a implementação removida. A afirmação que prende a regra é a identidade: convert() sempre devolve uma superfície nova quando funciona, e a mesma quando não há formato. Daí os testes do auxiliar (opaca vira formato do display sem ganhar alfa; SRCALPHA mantém o vazado e as cores; sem display volta o mesmo objeto) e, nos dois pontos de uso, um espião que envolve o convert de verdade e registra o que ele devolveu, para o teste afirmar que a superfície entregue é exatamente a que passou por lá. Mais dois de comportamento: a textura da abelha chega com o canto transparente, e o cache da fonte converte uma vez por (texto, escala, cor) e serve a mesma superfície nas chamadas seguintes.

Conferido que os testes prendem mesmo a regra, por mutação — tirar o convert de generate_all, do cache da fonte ou do make_image quebra um teste cada; fixar convert_alpha() para tudo quebra dois; fixar convert() para tudo quebra três; trocar a degradação por raise quebra dois. Suíte completa verde, ruff check, ruff format --check e ty check sem violações. Exercitado no driver real: 30 frames em gpu-accelerated sem exceção, e no caminho de superfície com janela real as texturas e os glifos saem convertidos, com o texto desenhando sobre o fundo sem caixa opaca atrás.

  • [x] 53. Chão pré-renderizado Uma faixa de canvas_w + BLOCK por par (block_main, block_edge), cacheada, com o rolamento virando deslocamento do retângulo de origem. De 22 blits para 1 draw call. Teste: um frame de chão emite exatamente uma chamada de desenho, e o resultado visual bate com o da implementação anterior para o mesmo deslocamento. (R27.2)

Feito. _make_strip constrói a parede inteira — fileira de borda no topo, preenchimento até a base do canvas — numa superfície de canvas_w + BLOCK por canvas_h - ground_y(), e Ground.draw virou uma chamada: renderer.draw(strip, (0, top_y), Rect(BLOCK - round(offset), 0, canvas_w, height)). O bloco a mais de largura é o que dá lugar ao rolamento — a faixa é periódica em BLOCK, então deslocar a origem de 0 a BLOCK percorre o ciclo inteiro, e em qualquer ponto dele o recorte cabe dentro da faixa sem precisar de uma segunda passada para tapar a sobra.

A equivalência é aritmética, e vale a pena escrevê-la. Na v2 o pixel de canvas x caía na coluna de textura (x - round(offset) + BLOCK) mod BLOCK, porque os blocos saíam de round(offset) - BLOCK de BLOCK em BLOCK. Na faixa, x vira o pixel x + BLOCK - round(offset) dela, e como os ladrilhos estão em múltiplos de BLOCK a partir de 0, a coluna de textura é a mesma expressão. É por isso que a comparação nos testes é de igualdade exata de pixel, e não aproximada: qualquer diferença seria erro de geometria, não de arredondamento.

Medido (scripts/benchmark.py --frames 200, canvas 960x720 do desktop, onde o chão são 21 colunas × 2 fileiras): 42 blits por frame viraram 1, e o total de draw calls caiu de 110 para 69 em PRONTO, 116 → 74 no overworld, 121 → 80 na cave, 83 → 42 no nether e 123 → 82 em GAME_OVER. O p50 e o p95 não mudaram além do ruído (~9,5 ms e ~11,5 ms): sob o driver dummy a suíte roda no caminho de superfície, onde um blit opaco de 48x48 é barato e o frame é dominado por outras coisas. O ganho de draw call é do caminho de GPU do celular, onde cada desenho é uma chamada ao SDL — e as tasks 54 a 56 atacam justamente o que ainda domina esse total.

Testes (tests/test_ground.py, novo — 24 casos, 258 no total). O arquivo carrega uma reprodução literal do laço da v2, _draw_the_v2_way, e ela faz dois papéis. É a referência da equivalência visual — 24 combinações de tela (2:3, celular, wide), deslocamento (0, 1.4, 24, 47.6) e bioma (overworld, nether), comparando pixel a pixel toda a faixa de chão. E é a âncora do teste de contagem: sem ela, "1 desenho por frame" não diria de quanto foi a queda, então há um teste que afirma que o mesmo frame custava 22 desenhos no canvas 2:3. Mais: a faixa é construída uma vez e sobrevive a três frames de rolamento; cada par de blocos ganha a sua (R7.3, sem congelar o bioma); o destino é sempre o canvas inteiro e quem anda é a origem; e o recorte nunca sai da faixa ao longo de 200 passos com velocidade quebrada. Uma âncora contra vacuidade fecha o conjunto — nenhum pixel de chão continua magenta, nem na coluna final parcial nem na fileira que desce pela faixa decorativa (R25.1).

Conferido por mutação: tirar o BLOCK da origem, congelar o rolamento, encolher a faixa para canvas_w, trocar a fileira de borda por preenchimento, comer uma fileira ou tirar o bioma da chave — cada uma quebra de 1 a 24 testes. E conferido no renderizador acelerado de verdade (fora do dummy): o srcrect da GPU produz saída idêntica pixel a pixel à do laço da v2 nos três deslocamentos, sem buraco nenhum. ruff check, ruff format --check e ty check sem violações.

  • [x] 54. Colunas pré-renderizadas e descarte por posição Duas strips de altura do canvas por par de blocos (borda embaixo e borda em cima), recortadas por area. De ~24 para 2 draw calls por coluna. Somar descarte das colunas inteiramente fora da área visível — a v2 sempre desenhava um par fora da tela. Testes: duas chamadas por coluna visível; zero chamadas para uma coluna fora da área visível. (R27.2, R27.7)

Feito. _make_strip empilha blocos numa faixa de PIPE_W por altura de canvas, com a fileira de borda numa das pontas — embaixo para a coluna de cima, em cima para a de baixo —, e PipePair.draw virou dois recortes. O de cima sai da base da faixa (Rect(0, height - top.height, ...) desenhado em y = 0), porque é lá que está a fileira de borda e é ela que precisa cair exatamente na beirada da abertura; o de baixo sai do topo, desenhado em bottom.top. A altura de canvas é o maior corte que qualquer coluna pode pedir, já que a abertura nunca encosta na borda de cima nem no chão.

Uma guarda que a v2 não precisava. Com gap_size 160 e GAP_MARGIN 80, gap_y no mínimo faz a coluna de cima ter altura zero — e num canvas 2:3 esse mínimo é alcançável. O laço da v2 simplesmente não entrava; um area de altura 0 não é desenho, então as duas metades passaram a ser condicionadas a height > 0. Tem teste próprio, com âncora afirmando que o caso é mesmo esse.

Descarte contra o canvas, não contra a área jogável (R27.7). PipePair.visible() é -PIPE_W < round(x) < screen_w(). A escolha importa: numa tela larga a coluna nasce em play.right, que fica dentro do canvas, e lá ela existe de verdade — quem a esconde é a faixa lateral, desenhada depois (R24.4). Descartar contra a área jogável abriria um buraco onde a faixa não cobrisse. Num canvas 2:3, play.right é a borda da tela, e é aí que o descarte recupera a coluna inteira que a v2 desenhava por frame sem ninguém poder ver. Os dois lados têm teste, e a mutação que troca screen_w() por play().right quebra quatro.

Medido (scripts/benchmark.py --frames 200, canvas 960x720): draw calls por frame caíram de 69 para 60 em PRONTO, 75 → 59 no overworld, 80 → 63 na cave, 42 → 25 no nether e 82 → 73 em GAME_OVER. p50 e p95 seguem no ruído (~9,7 ms / ~12 ms) pelo mesmo motivo da task 53 — o benchmark roda o caminho de superfície sob dummy. A estimativa de "~24" desta task estava alta: a coluna mede ground_y() - gap_size de altura útil, o que dá 464px ÷ 48 = 10 blocos no canvas 2:3 e ~16 no canvas alto de celular, onde a metade de cima atravessa a faixa de céu. O teste-âncora fixa o número medido (10) em vez do estimado.

Testes (20 novos em tests/test_pipes.py, 285 no total). Mesmo padrão da task 53: uma reprodução literal do PipePair.draw da v2 serve de referência para a equivalência visual (12 combinações de tela × posição da abertura × bioma, igualdade exata de pixel) e de âncora para a contagem. Mais: cada metade vem da sua própria faixa (não é a mesma virada); as duas faixas são construídas uma vez e servem as três colunas seguintes; um bioma diferente pede o seu par (R2.5); a visibilidade tem teste nas bordas exatas, um pixel de cada lado; e a coluna que sai pela esquerda continua desenhada até o último pixel.

Divergência conhecida e sem consequência, encontrada na comparação: a v2 desenhava o último bloco da metade de baixo inteiro, transbordando até 47px por baixo de ground_y(); a faixa recorta exatamente na linha. O chão é desenhado depois das colunas e cobre os dois casos igual, então a comparação de pixel vai de 0 a ground_y() — está dito na docstring do teste, não escondido no intervalo.

Dois testes existentes precisaram de ajuste, porque identificavam a coluna pela chave da imagem ("dirt", "grass_side") e essas chaves deixaram de ser pedidas por pipes.py: test_the_pipe_under_the_band_is_drawn_before_it e test_the_side_bands_are_drawn_after_the_pipes passaram a reconhecer ("pipe_top", ...)/("pipe_bottom", ...), e o segundo ganhou uma âncora afirmando que há coluna na tela — sem ela o max() sobre lista vazia era o que denunciava a mudança, o que é sorte e não teste.

Conferido por mutação (descarte contra a área jogável, sem descarte, recorte de cima pelo topo, borda no lado errado, recorte de baixo deslocado, guarda de altura zero afrouxada, bioma fora da chave): cada uma quebra de 1 a 12 testes. E no renderizador acelerado de verdade: as duas faixas recortadas produzem saída idêntica pixel a pixel à do laço da v2 nas três aberturas testadas, com os 464px de coluna presentes. ruff check, ruff format --check e ty check sem violações.

  • [x] 55. Parallax pré-renderizado Cada camada de decor.py vira uma strip de period × N tiles construída uma vez por bioma, desenhada com wrap-around. Elimina os random.Random por frame, os draw.rect e os Rect intermediários. Teste: o desenho da strip é idêntico ao produzido pelo caminho antigo para os mesmos índices (comparação pixel a pixel da superfície gerada), e nenhum random.Random é instanciado durante o desenho. (R27.2, R27.3)

Feito. _make_strip pinta os ladrilhos numa faixa periódica e DecorManager._layer desenha um recorte dela — dois quando a origem chega perto do fim e o que falta vem do começo. Os desenhadores de _LAYERS não foram tocados: o que mudou foi o alvo, via _StripPainter, um render.Renderer de cinco linhas que pinta numa Surface em vez de num canvas de frame. Trocar o alvo em vez de reescrever a geometria é o que torna a equivalência visual verificável — a forma continua saindo do mesmo código de antes, e é por isso que a comparação nos testes é de igualdade exata de pixel.

A faixa impõe um período ao fundo, que até então não tinha nenhum. É a única mudança de comportamento, e é o preço de trocar o sorteio por pixels prontos: o ladrilho de índice i do mundo vira o ladrilho i % tiles da faixa. _tile_count dimensiona a faixa em 3 larguras de canvas (mínimo de 4 ladrilhos), o que põe a volta do ciclo a algumas dezenas de segundos de rolagem, além de uma partida típica. Seguir a largura do canvas em vez de fixar N é o que equaliza as seis camadas: períodos de 100 a 220px produzem faixas de largura parecida, e portanto de memória e de ciclo parecidos.

O recorte vertical é o que faz isso caber num celular. Uma camada ocupa uma faixa estreita da altura do canvas — nuvens lá em cima, colinas rentes ao chão —, então _build mede o conteúdo com get_bounding_rect e joga fora as linhas vazias. Medido: no canvas de celular (480×1067) as seis camadas somam 5,9 MB contra 30,8 MB sem o recorte; no canvas 2:3, 4,7 MB contra 22,0 MB. A medida sai dos pixels de verdade, e não de uma altura declarada à mão por camada, justamente para nunca discordar do que o desenhador desenhou. O topo medido vai para _STRIP_TOPS, no módulo: é memoização de uma função pura da chave — um inteiro, nunca pixels —, e guardá-lo junto da imagem exigiria que Renderer.image soubesse carregar metadado, o que só esta camada precisa.

As duas pontas são pintadas mais uma vez do lado de fora (o laço vai de −1 a tiles), porque na emenda o vizinho de um ladrilho é a outra ponta da faixa. Não é hipótese: ore_vein_shapes sorteia cada cubo até 15px à esquerda do seu ladrilho, então sem a repintura passaria uma coluna de 15px sem minério pela tela a cada volta do ciclo.

A única divergência em relação ao código literal da v2 é um defeito dela, e foi encontrado pela comparação. O laço da v2 parava no primeiro ladrilho com x >= largura — e os cubos de minério que esse ladrilho projetava de volta para dentro da tela nunca eram desenhados, surgindo de uma vez quando a rolagem o trazia para dentro do intervalo. Varredura de 3 telas × 3 biomas × até 60 deslocamentos: a diferença aparece em 18 casos, sempre nos últimos 15px da tela, e sempre com o caminho antigo faltando conteúdo. Por isso a referência dos testes ganha um ladrilho de folga à direita; com ele, as mesmas 540 comparações dão zero pixels de diferença. Há um segundo transbordo no parallax e ele não produz diferença nenhuma: a colina de HILL_SHAPES[1] mede 162px contra um período de 150, mas a fileira que transborda é a da base, e a base de qualquer colina começa na coluna 0 do próprio ladrilho — o vizinho sempre repinta por cima, na mesma cor. Os dois fatos têm teste.

Medido (scripts/benchmark.py --frames 200, canvas 960x720 do desktop): draw calls por frame caíram de 60 para 18 em PRONTO, 59 → 14 no overworld, 63 → 14 na cave, 25 → 14 no nether e 73 → 31 em GAME_OVER. Pela primeira vez desde a task 53 o p50 se mexeu fora do ruído — 18,6 → 14,1 ms no overworld, 22,2 → 13,6 na cave, 23,8 → 14,8 no nether —, e a memória transitória por frame caiu de 2,9 KB para 0,6 KB. O ganho de tempo aqui não é de draw call: é o Mersenne Twister (estado de 624 palavras) que a v2 instanciava por ladrilho por frame para sortear de novo exatamente a mesma forma que o índice já determinava (R27.3). Era o caso extremo da seção 30 do design, e some inteiro.

Testes (tests/test_decor.py, reescrito — 35 casos, 317 no total). O núcleo é a equivalência visual: 18 combinações de tela (2:3, celular, wide) × bioma × deslocamento, igualdade exata de pixel contra uma reprodução do _tile da v2. Em volta dela, três âncoras contra vacuidade — o caminho antigo custava 25/24/6 desenhos por frame nos três biomas, criava um random.Random por ladrilho, e cada bioma deixa mais de 200 pixels próprios na tela (as duas implementações poderiam concordar por não desenharem nada). Mais: a emenda da faixa comparada contra o laço da v2 alimentado com a sequência idx % tiles; os dois recortes cobrindo o canvas sem buraco nem sobreposição ao longo de um ciclo inteiro; uma faixa por camada por bioma sobrevivendo a 50 frames; a faixa medindo ao menos 3 canvas de largura; e nenhuma linha vazia acima ou abaixo do conteúdo, com âncora fixando que a faixa de colinas mede 72px contra 875 de canvas.

Conferido por mutação (sem os ladrilhos de wrap nas pontas, sem o segundo recorte, rolamento sem módulo, faixa desenhada no topo do canvas, sem recorte vertical, bioma fora da chave, camada fora da chave, faixa de tamanho fixo, índice sem wrap na construção): 9 de 9 mortas. Uma décima mutação sobrevive e está certo que sobreviva — construir a faixa com a altura do canvas em vez de ground_y() produz saída idêntica, porque nada do parallax desenha abaixo da linha do chão; a diferença é só de alocação no momento da construção. E no renderizador acelerado de verdade (fora do dummy): saída idêntica pixel a pixel à do laço da v2 nos três biomas × três deslocamentos, e o jogo roda 120 frames em cada bioma mais PAUSADO e GAME_OVER sem exceção. ruff check, ruff format --check e ty check sem violações.

  • [x] 56. Abelha, escurecimento e ícone de mudo pré-renderizados As 62 combinações de (ângulo, frame de asa) da abelha viram texturas pré-computadas; Bird.draw vira consulta e um draw call. O escurecimento de PAUSADO/GAME_OVER vira fill com blend, eliminando a Surface de ~1,4 MB alocada por frame. O ícone de mudo vira duas texturas. Testes: nenhum transform.rotate durante o desenho; nenhuma Surface criada durante o desenho do overlay; o ângulo fora da grade é quantizado para a textura mais próxima. (R27.2, R27.3)

Duas das três metades já estavam feitas, e isso precisa ser dito antes do resto. O escurecimento virou renderer.fill(OVERLAY_COLOR, ...) e o ícone de mudo virou imagem chaveada por estado na task 50, quando ui.py migrou para o renderizador — as duas mudanças estão em 3398e65, com teste desde lá. O que sobrou de trabalho aqui foi a abelha. O que sobrou de verificação foi a segunda metade do teste que esta task pede sobre o overlay, e ela não era verdade ainda: test_dim_overlay_is_a_fill_and_allocates_no_surface afirmava sobre um FakeRenderer, onde nada alocaria de qualquer forma — o fill só anota a cor. Quem de fato precisava de uma superfície é o caminho de superfície, que mistura o alfa à mão. O teste foi partido em dois: um sobre a chamada, no fake; outro sobre a memória, contra um SurfaceRenderer de verdade, com pygame.Surface proibido depois do primeiro frame.

A grade de ângulos não é uma aproximação imposta ao movimento — é o conjunto que a física já produzia. flap fixa em +30 e a queda decrementa de 3 em 3 até −60, então os ângulos alcançáveis são exatamente range(-60, 31, 3): 31 valores, × 2 frames de asa = 62 texturas. ANGLES só torna isso explícito, e é o que permite pré-computar em vez de descobrir uma a uma. Tem teste-âncora: uma simulação de três subidas e quedas completas passa por todos os 31, e por nenhum outro.

quantize existe para que isso deixe de ser suposição. No jogo ela devolve o próprio valor. Sem ela, um ângulo fora da grade — de um passo de queda ajustado no futuro, de um estado restaurado, de um teste — pediria uma textura que ninguém pré-computou, e a rotação voltaria para dentro do frame, exatamente onde não pode estar. O round() solto que havia em draw não dava essa garantia: arredonda para o inteiro, não para a grade.

Uma armadilha do redimensionamento. forget_images não sabe distinguir o que depende do canvas do que não depende, então as 62 rotações caíam junto com o gradiente de céu — e voltariam uma por frame durante a queda seguinte, que é precisamente o cenário que a task existe para eliminar. apply_resize passou a refazê-las ali, fora do frame. O teste existente test_resize_discards_the_images_keyed_by_canvas_size foi ajustado para afirmar o que agora é verdade — o que sobra depois do resize são exatamente as 62 chaves da abelha — em vez de == {}, que teria sido a leitura errada da mudança.

Medido. A pré-computação custa 3,8 ms na inicialização e 0,88 MB para as 62 texturas (a maior mede 67×67, contra os 48×48 do sprite). Uma única subida e queda pede 33 texturas distintas — antes desta task, construídas uma por frame durante a primeira descida, cada uma com uma Surface nova, uma reamostragem de 48×48 e um envio para a VRAM dentro do frame (R27.3).

O benchmark não se mexe, e é esperado que não se mexa. scripts/benchmark.py --frames 200 continua em 18/14/14/14/30 draw calls e ~0,6 KB transitórios por frame, dentro do ruído da task 55. O motivo é que o cache por (frame, ângulo) existe desde a task 50: em regime permanente a v3 já não rotacionava nada. O que esta task muda é o transiente — os primeiros segundos de partida, e os primeiros segundos depois de cada redimensionamento — e a garantia de que não há caminho de volta para a rotação no frame. Registrar isso como "sem ganho medido" seria mais honesto que procurar um número que a mudança não produz.

Testes (tests/test_bird_sprites.py, novo — 19 casos; mais 1 em test_ui_layout.py; 337 no total). O central é o direto: com pygame.transform.rotate proibido, 180 frames de voo completo desenham 180 vezes sem tocá-lo. Em volta: a grade tem 31 valores e a física passa por todos; a quantização prende os vizinhos (13→12, 14→15) e os limites (100→30, −100→−60) e chega até o desenho, sem construir nada novo; precompute_sprites constrói as 62 combinações e é idempotente; o sprite rotacionado continua centrado na abelha nos três ângulos extremos, com tolerância de 1px; o canvas não entra na chave, então três telas diferentes seguem servidas pelas mesmas 62; e um resize refaz todas, com o desenho seguinte proibido de rotacionar. A âncora contra vacuidade reproduz o draw da v2 e mostra as 180 rotações que ele pagava para chegar a 31 resultados distintos.

Conferido por mutação (sem pré-computação na inicialização, sem refazer no resize, quantização trocada por round(), quantização sem os limites, grade sem o ângulo de cima, um só frame de asa, frame de asa fora da chave, sprite ancorado pelo canto, superfície de mistura recriada a cada fill): 9 de 9 mortas — e a última é morta tanto pelo teste de render.py quanto pelo novo teste de overlay, conferido em separado. E no renderizador acelerado de verdade (fora do dummy): as 62 combinações produzem saída idêntica pixel a pixel à do rotate na hora, e o jogo roda 120 frames em cada bioma mais PAUSADO e GAME_OVER sem exceção. ruff check, ruff format --check e ty check sem violações.

  • [x] 57. Mobs decorativos (src/mobs.py) Nove sprites voxel 16×16 novos em textures.py, no estilo procedural de make_bee, com 2 frames de idle cada — Overworld: creeper, bruxa, aldeão; Cave: enderman, aranha, esqueleto; Nether: ghast, blaze, piglin. Posicionados apenas nas faixas decorativas, com deriva de parallax própria mais lenta que a camada distante. mobs.py recebe apenas Viewport, bioma e deslocamento — sem acesso ao estado de jogo. Testes: todo mob desenhado cai fora de viewport.play; a colisão ignora os mobs; a pontuação não muda com mobs em tela; cada bioma tem ao menos três variedades e cada sprite tem dois frames distintos. (R25.3, R25.4)

O que garante R25.4 é o recorte, não a disciplina de quem desenha. Cada mob é cortado contra o retângulo da faixa antes de ir para a tela — rect.clip(band) vira o area do renderer.draw —, e as faixas são disjuntas de viewport.play por construção (viewport.compute posiciona a área jogável exatamente onde as faixas terminam). Um pixel de mob dentro do campo de jogo não tem por onde acontecer, e a alternativa óbvia — "só desenha se couber inteiro" — faria o mob sumir de uma vez ao chegar na borda. Com o recorte ele desliza para trás da parede de terra, que é o que a lateral já promete visualmente (poço de mineração). Tem teste pelos dois lados: a varredura de um ciclo inteiro sem nenhum retângulo tocando play, e a captura dos desenhos cujo area é mais estreito que o sprite.

A prova mais forte de R25.4 não é sobre pixels, é sobre dependência. mobs.py importa config, render e textures — e mais nada. Não há caminho pelo qual um mob colida, pontue ou mude velocidade, porque não há caminho pelo qual ele alcance qualquer uma dessas coisas. O teste lê os imports do fonte com ast e afirma o conjunto exato, com âncora aplicando a mesma leitura ao game.py para mostrar que ela enxergaria a diferença. É por isso também que a cadência de idle é uma constante repetida em vez de importada de bird.py: seis linhas de comentário não valem abrir a primeira exceção à regra que torna o requisito verificável.

O teste central é uma partida inteira comparada consigo mesma. Enumerar "não colide, não pontua, não muda velocidade" deixaria de fora justamente a regra que ninguém pensou em enumerar. Em vez disso: duas partidas de 400 frames com semente fixa — uma pilotada, que pontua e troca de bioma; outra flapando às cegas, que colide e vai para GAME_OVER — gravando estado, pontuação, posição, velocidade, posição de cada coluna e bioma por frame. Com os mobs desligados, os 800 frames saem idênticos. As duas âncoras (pontuou de verdade, colidiu de verdade) impedem que a igualdade venha de uma partida que não fez nada.

Sky band e faixa lateral nunca coexistem, e isso mudou o desenho dos testes. O canvas recebe a proporção da tela, então só um dos eixos pode sobrar: sky_extra e left_band.width nunca são ambos positivos. A verificação da ordem de desenho da seção 32.6 — mobs do céu antes das colunas, mobs das laterais depois das faixas opacas — teve portanto que virar dois testes em duas telas, e o fato ficou registrado como teste próprio, porque a primeira tentativa de escrevê-lo numa tela só passou meia hora procurando uma geometria que não existe.

A distribuição é sorteada uma vez e memoizada, e o rodízio é o que torna R25.3 uma garantia. Um campo é uma tupla de posições (variedade, x, y, fase), função pura da chave (bioma, faixa, período, topo, altura, largura do canvas) — mesma justificativa de decor._STRIP_TOPS para viver no módulo. As três variedades entram por index % 3 em vez de sorteio: assim "pelo menos três por bioma" vale em qualquer geometria, e não por sorte num canvas estreito. O desvio dentro de cada período nunca passa de period - MOB_SIZE, então dois vizinhos ficam sempre a pelo menos um sprite de distância — inclusive na emenda do ciclo, que é onde a conta seria fácil de errar, e tem teste.

A deriva dá a volta, e a faixa nunca esvazia. O deslocamento é tomado módulo o comprimento do campo e a posição de cada mob também, então quem sai por uma ponta volta pela outra. Sem isso a faixa iria se esvaziando até o ciclo reiniciar — e foi exatamente a mutação que sobreviveu à primeira rodada: o teste de periodicidade comparava scrolled = 0 com scrolled = span, que dão o mesmo deslocamento nos dois casos. O teste que a mata varre o ciclo inteiro e afirma o piso e o teto de mobs em cena (2–4 na faixa de céu, 3–7 nas laterais): o piso é a continuidade, o teto é o orçamento de draw calls.

Uma divergência assumida em relação ao design. A seção 35 pede deriva mais lenta que a camada distante "para ficarem claramente ao fundo" — MOB_FACTOR é 0.15 contra os 0.3 de decor.FAR_FACTOR —, mas a ordem da seção 32.6 desenha os mobs depois do parallax, então uma nuvem passa por trás de um aldeão que anda mais devagar que ela. As duas seções do design discordam; a ordem de desenho foi seguida porque é a que a faixa lateral opaca exige de qualquer forma (mob desenhado antes dela sumiria atrás da terra), e porque mexer nela para o céu criaria dois caminhos onde há um. O efeito é discreto no aparelho: são 32px contra 480 de largura, no fundo.

Medido (scripts/benchmark.py --frames 200, canvas 960×720, contra uma execução do mesmo benchmark com os mobs desligados na mesma máquina, duas rodadas de cada): draw calls por frame de 18→21 em PRONTO, 14→17 no overworld e na cave, 14→18 no nether e 31→34 em GAME_OVER — +3 a +4, contra os "~2" orçados na seção 34. O p50 não se move fora do ruído (9,4–9,5 ms com e sem) e a memória transitória por frame continua em 0,6 KB: um mob é uma consulta no cache e um draw, sem alocação. A pré-computação dos 18 sprites custa 1,25 ms na inicialização e ~72 KB de VRAM (32×32×4 cada) — o dobro exato dos 16×16 de origem, escala inteira para não haver pixel de largura desigual, e menor que os 48 da abelha para o cenário nunca ser confundido com o jogador.

Testes (tests/test_mobs.py, novo — 46 casos; mais 1 ajustado em test_resize.py; 383 no total). Além dos centrais já descritos: as três variedades presentes em todo campo de toda geometria; os dois frames de cada um dos nove sprites diferindo pixel a pixel, com piso de 40 pixels opacos para nenhum ser quase vazio; a alternância de idle na cadência da asa e as duas fases conviverem em cena; nenhum mob numa tela 2:3 e nenhum numa faixa de céu mais baixa que um sprite; um Game recém-criado desenhando 120 frames com scale_pixel_perfect proibido; o resize refazendo os 18; e a abelha voando por dentro do retângulo de um mob sem colidir. O test_resize que afirmava "o que sobra depois do resize são as 62 chaves da abelha" passou a afirmar 62 + 18.

Conferido por mutação (sem recorte contra a faixa, recorte contra o canvas em vez da faixa, deriva igual à da camada distante, sem a volta do ciclo, faixa de céu baixa não recusada, todas as posições na mesma fase, campo de tamanho fixo, uma só variedade por bioma, desvio sem limite, idle sem alternância, laterais desenhadas antes da faixa opaca, céu desenhado depois das colunas, sem refazer no resize, sem pré-computar na inicialização, dois frames de idle iguais): 15 de 15 mortas — duas delas só depois dos testes que a primeira rodada mostrou faltar. E no renderizador acelerado de verdade (fora do dummy): 140 frames em cada bioma no canvas do desktop e 200 no canvas de celular (BLOCKY_CANVAS=480x1067), com as três variedades visíveis em cada faixa e o recorte na borda conferido na captura. ruff check, ruff format --check e ty check sem violações.

Fora do escopo, encontrado aqui. tests/test_game.py::test_bare_tv_remote_reaches_every_state é instável (~0,6% por execução): ele força o fim de jogo colocando a abelha no teto, mas gap_y é sorteado do random global e ocasionalmente encosta no teto também, e aí a colisão não acontece. Não tem relação com esta task — ficou registrado para ser corrigido à parte.

Bloco F — Alocação e coletor de lixo

  • [x] 58. Eliminar alocações por frame Bird.rect, PipePair.top_rect/bottom_rect e Ground.rect deixam de ser property que alocam e viram retângulos persistentes atualizados no lugar, computados uma vez por passo. PipeManager.update e ParticleSystem.update param de recriar a lista por frame. __slots__ em Bird, PipePair e Particle. ui.draw_text calcula posição por aritmética em vez de dois get_rect(), e _fit_scale ganha cache. Teste: um passo de update + draw em JOGANDO não aumenta a contagem de blocos alocados além de um limite pequeno declarado. (R27.3)

A métrica que a task pede não mede o que a task faz, e isso teve que ser resolvido antes de escrever uma linha. "Contagem de blocos alocados" é sys.getallocatedblocks(), que é líquido: um retângulo criado e liberado dentro do frame não aparece. Medido antes da mudança, o saldo já era de 1,45 blocos por frame — quase zero —, então o teste pedido teria passado igual nos dois lados. Os KB transitórios do scripts/benchmark.py têm o mesmo problema por outro caminho: tracemalloc reporta a marca d'água do frame, e trinta objetos pequenos criados e destruídos um após o outro nunca chegam a estar vivos ao mesmo tempo. Medidos: 643 bytes/frame antes, 641 depois — ruído. A seção 30 do design chama essa coluna de "rotatividade de objetos Python"; ela é, na verdade, o pico simultâneo, e o registro fica aqui porque a task 73 vai reler aqueles números.

O que se mediu no lugar: quantos pygame.Rect um frame constrói. Uma subclasse de pygame.Rect posta no lugar do atributo do módulo conta todas as construções de todos os módulos de uma vez, sem que nenhum deles saiba que está sendo medido — todo o jogo constrói retângulos por pygame.Rect(...). Com semente fixa, canvas 960×720, duas colunas em cena e caches quentes, o número é exatamente o mesmo em todos os frames da janela de medição, o que permitiu um teto apertado em vez de uma média com folga.

Medido (tests/test_alloc.py, o mesmo instrumento nos dois lados; o "antes" é obtido repondo as quatro property sobre as classes, sem manter cópia do código da v2 por perto):

Recorte v2 v3 queda
Frame de JOGANDO (update + draw) 80 61 −24%
Passo de simulação (update sozinho) 15 0 −100%
Blocos líquidos por frame (getallocatedblocks) 1,45 0,63 —
KB transitórios por frame (tracemalloc) 0,6 0,6 sem movimento, e esperado

Os 15 do passo de simulação batem exatamente com o inventário da seção 30 do design ("Colisão | ~15 pygame.Rect alocados por frame por property"), que foi levantado por leitura de código na task 44 — a primeira confirmação numérica daquela linha. Os 61 que sobram no frame não são resíduo desta task: 18 são do render._dest_rect (um por draw call, inerente à interface), 22 dos mobs, 11 das faixas derivadas do viewport e 10 dos recortes de decor, colunas, chão e HUD. scripts/benchmark.py --frames 200 não se move fora do ruído (p50 9,3–10,7 ms, draw calls 21/17/17/18/34, 0,6 KB), como a explicação acima previa.

Metade da task já estava feita, e não por acaso. O design pede que ui.draw_text pare de chamar get_rect() duas vezes por texto; ela já não chamava desde a task 50, quando _centered passou a fazer a conta sobre image.size — a camada de render entregou isso de graça. O que faltava era o cache de _fit_scale, e ele está aqui. A chave leva o limite (max_text_w()) além do comprimento e da escala: hoje a área jogável é fixa em 480 e o limite nunca muda, mas amarrá-lo à chave é o que impede a resposta de uma tela vazar para outra se isso deixar de ser verdade — e é o que mata a mutação correspondente.

Uma adição além da lista: Particle.rect. A task nomeia Particle só para o __slots__, mas Particle.draw construía um retângulo por partícula por frame — 16 na tela de GAME_OVER, o cenário mais pesado da tabela. É o mesmo mecanismo das outras três, e ficou. Foi de onde saiu a maior parte da queda de 87 para 67 medida naquele cenário.

Uma consequência não prevista pelo design: o redimensionamento. PipePair.bottom_rect e Ground.rect dependem de config.ground_y(), que anda quando a janela muda de proporção. Enquanto eram property, isso se resolvia sozinho na próxima leitura; persistentes, precisam ser reposicionados — e o próximo passo de simulação não serve como gatilho, porque PRONTO, PAUSADO e GAME_OVER desenham sem simular. Game.apply_resize chama pipes.sync_rects() e ground.sync_rect(), e tem teste dos dois lados.

Ground.sync_rect() dentro de update foi escrito, e depois removido. Parecia simetria com Bird e PipePair, mas a geometria do chão não depende de nada que role durante a partida — só do canvas. A mutação que apagava a chamada sobreviveu à primeira rodada porque não havia o que quebrar: era código inerte. Saiu. sync_rect continua, chamada só por apply_resize.

Também fora do escopo, encontrado aqui: o scripts/benchmark.py não mede a colisão. _NoCollisionGame sobrescreve _collision_texture para retornar None sem chamar o original, então o cenário JOGANDO nunca paga a verificação — que era justamente o maior consumidor de retângulos da v2. Os números do benchmark continuam válidos para o que medem (desenho), mas subestimam o update. A fixture de tests/test_alloc.py usa um duble que chama o original e descarta o resultado, e é por isso que ela não reaproveita o do benchmark. Fica registrado para ser corrigido à parte.

Testes (tests/test_alloc.py, novo — 44 casos; mais 1 ajustado em test_mobs.py; 434 no total). Além dos centrais já descritos: as três classes sem __dict__ e recusando campo fora da lista; a hitbox idêntica à da property da v2 em seis posições, inclusive nos arredondamentos de meio pixel; as duas metades da coluna idênticas às property em três aberturas; identidade de objeto da hitbox, das metades, do retângulo do chão e da partícula ao longo de dezenas de passos; a lista de colunas atravessando 400 frames que criam e descartam sem nunca ser reconstruída, com âncora afirmando que os dois eventos ocorreram; o descarte filtrando exatamente o mesmo que a compreensão filtrava; a partícula desenhando a partir do objeto que guarda (identidade, não geometria — um draw que construísse um retângulo novo nas coordenadas certas passaria em qualquer teste de posição); e o _fit_scale medindo uma vez por forma, respondendo por comprimento e não por conteúdo, e ainda encolhendo o que não cabe.

Conferido por mutação (update e update_idle sem sync_rect; sync_rect ancorando pelo canto; PipeManager.update sem sync_rects; top_rect ignorando a abertura; bottom_rect ignorando a linha do chão; apply_resize sem cada um dos dois sync; volta da compreensão de lista nas colunas e nas partículas; descarte pulando o vizinho nos dois; descarte que nunca descarta; descarte que apaga a viva; partícula com retângulo novo no draw; _fit_scale sem cache e com cache sem o limite na chave; __slots__ removido das três classes; sync_rect do chão sem cada uma das duas atribuições): 21 de 21 mortas, três delas só depois de testes que as duas primeiras rodadas mostraram faltar. As duas lições ficaram registradas nos próprios testes: um descarte que pula o vizinho precisa de mortes vizinhas para ser flagrado, e precisa ser conferido a cada passo — a que ficou para trás é apagada no passo seguinte, então dois update() seguidos escondem o erro atrás da correção.

E no renderizador acelerado de verdade (fora do dummy, backend: gpu-accelerated): 120 frames em PRONTO, 140 em cada bioma, 60 em PAUSADO, 120 em GAME_OVER com partículas sempre vivas, e 120 depois de um redimensionamento para 1080×2400 — sem exceção, com bottom_rect.bottom e ground.rect.y os dois em 875, a nova linha do chão. ruff check, ruff format --check e ty check sem violações.

  • [x] 59. Ajuste do coletor de lixo (perf.tune_gc()) gc.collect() + gc.freeze() + gc.set_threshold elevado ao fim de Game.__init__, sem gc.disable() (que trocaria pausa por vazamento). Teste: após tune_gc(), a contagem de objetos permanentes é maior que zero e os limiares estão acima do padrão. (R27.3)

Como escrito no design, e no fim de __init__ por um motivo verificável. No aparelho de referência do desktop, Game() congela 16.329 objetos — texturas, as 62 rotações da abelha, os 18 sprites de mob, tabelas de glifos, definições de bioma, a cena montada pelo reset(). Tudo isso sai da varredura de uma vez. Que a chamada esteja no fim não é detalhe de estilo: o teste afirma que game.textures e game.pipes.pipes não aparecem em gc.get_objects(), que não enxerga a geração permanente — mover tune_gc() para antes do reset() deixa a lista de colunas visível e o teste falha.

gc.disable() continua descartado, e agora com teste. A decisão da seção 36 do design era só prosa; aqui ela vira uma afirmação verificável: depois de tune_gc(), um ciclo criado em tempo de execução ainda é recolhido por gc.collect(). É essa a diferença entre "elevar o limiar" e "desligar", e a mutação que acrescenta gc.disable() morre nela.

O preço foi cobrado no processo de teste, não no jogo — e cobrou um bug real. gc.freeze() é permanente e global, e a suíte cria mais de 150 Game. Sem contramedida, cada um mandaria para a geração permanente tudo que estivesse vivo naquele instante, inclusive o lixo cíclico dos testes anteriores, que nunca mais seria recolhido. Um fixture de conftest.py desfaz o congelamento e devolve os limiares depois de cada teste. Aí apareceu o segundo efeito, esse não previsto: com o gc.collect() de tune_gc() rodando dentro de Game.__init__, um renderizador de GPU órfão de um teste anterior passava a ser destruído no meio da construção do renderizador novo — e o Window.__del__ dele derruba a janela nova, porque o SDL reaproveita os ponteiros. Sintoma: Renderer's window has been destroyed, can't use further num teste que não tinha nada a ver. É exatamente o problema que scripts/benchmark.py::_stop_counting documenta desde a task 44; a correção é a mesma — um gc.collect() no fixture _reset_display, antes de trocar o display, para que cada órfão morra ainda com o seu próprio display vivo.

Sem ganho medido, e é esperado que não haja. Os limiares altos e o congelamento atacam a cauda — a varredura que cai no meio de um frame —, e o benchmark reporta p50 e p95 sobre 200 frames num desktop x86 onde a geração 0 nem chega perto de encher (gc.get_count()[0] fica em 14 depois de uma partida inteira, contra o limiar de 50.000). O efeito que se pode afirmar hoje é estrutural: 16.329 objetos fora da varredura, e uma partida de 300 frames que não enche a geração 0. Procurar um número de FPS aqui, nesta máquina, seria procurar o que a mudança não produz.

Testes (em tests/test_alloc.py, junto com a task 58 — é a mesma seção 36 do design). O congelamento saindo do zero, com âncora afirmando que a suíte desfaz o congelamento entre testes; os três limiares acima do padrão; o coletor continuando ligado e recolhendo um ciclo criado depois do ajuste; Game() deixando os limiares ajustados e as suas próprias estruturas fora da lista de rastreados; tune_gc chamado exatamente uma vez; e uma partida de 300 frames que não enche a geração 0.

Conferido por mutação (tune_gc sem freeze, sem set_threshold, com gc.disable() acrescentado; Game.__init__ sem chamar tune_gc, chamando antes do reset(), e chamando duas vezes): 6 de 6 mortas.

  • [x] 60. Persistência do recorde fora do frame _update_score passa a marcar o recorde como sujo em vez de gravar em disco; a gravação acontece em GAME_OVER e em APP_WILLENTERBACKGROUND. Preserva a garantia de R4.3/R16.4 que motivou o desenho original. Testes: bater o recorde em JOGANDO não escreve em disco; chegar a GAME_OVER escreve; ir para segundo plano com recorde sujo escreve; ir para segundo plano sem recorde novo não escreve. (R27.5, R4.3, R16.4)

R4.3 e R27.5 se contradizem no texto, e a task escolheu R27.5. R4.3 diz "sem esperar o fim da partida"; R27.5 diz "no fim da partida ou quando o aplicativo vai para segundo plano". Não é ambiguidade a resolver, é uma revogação: R27.5 é o requisito novo da v3 e existe justamente para desfazer a decisão de R4.3, preservando a garantia que a motivou em vez do mecanismo. R16.4 ainda credita a garantia à "gravação incremental de R4.3" e ficou desatualizado no texto; o requisito em si (o recorde sobrevive ao encerramento pelo sistema) continua valendo, pelo caminho novo. Registrado aqui porque os três textos vão para a matriz de rastreabilidade na task 67.

O que sustenta a garantia é o aviso, não a pressa. O Android emite APP_WILLENTERBACKGROUND antes de poder encerrar o app, e é nesse aviso que a gravação passa a acontecer. O caso que se perde — processo morto sem aviso nenhum, entre a superação do recorde e o próximo evento — a v2 também não cobria: ela gravava no instante da superação, mas o ponto seguinte já estava igualmente exposto até a gravação seguinte.

ACTION_FOCUS_LOST é um superconjunto de APP_WILLENTERBACKGROUND, e isso é bom. input.py mapeia APP_WILLENTERBACKGROUND, APP_DIDENTERBACKGROUND e WINDOWFOCUSLOST para a mesma ação desde a v2. Usá-la significa que um alt-tab no desktop também descarrega o recorde — mais oportunidades de gravar, nenhuma delas dentro do frame de JOGANDO, que é o que R27.5 proíbe.

Um terceiro ponto de gravação, além dos dois que a task pede. Sair do laço (fechar a janela, ou BACK fora de JOGANDO) grava o que estiver pendente. Sem ele, quem batesse o recorde e abandonasse a partida sem colidir perderia o recorde no caminho mais banal que existe no desktop — e nem GAME_OVER nem segundo plano teriam acontecido. É uma linha no fim de run(), com teste.

A chamada fica fora do if de estado, e há teste para isso. O _flush_highscore de handle_events roda antes da verificação state == JOGANDO que decide a pausa. Prendê-lo ao estado parece inofensivo — quem tem recorde sujo estava jogando — mas quebra o caso de pausar depois de bater o recorde e só então trocar de app. A mutação que o prende ao estado morre nesse teste.

Um teste da v2 foi invertido, não removido. test_highscore_saved_incrementally_mid_round afirmava exatamente o que R27.5 agora proíbe. No lugar dele entrou test_beating_the_record_while_playing_does_not_touch_the_disk, que afirma o contrário sobre o mesmo cenário.

Testes (em tests/test_game.py — 8 novos, 1 invertido). Bater o recorde em JOGANDO não escreve; uma partida inteira com seis pontuações não escreve nenhuma vez; GAME_OVER escreve; segundo plano com recorde pendente escreve e continua pausando (R16.1); segundo plano estando PAUSADO também escreve; segundo plano sem recorde novo não escreve nada; a gravação não se repete depois de já ter acontecido; e a saída do laço grava a partida abandonada. A espionagem é sobre score.save_highscore — e não sobre o conteúdo do arquivo — porque R27.5 é sobre a chamada de sistema dentro do frame, não sobre o que ficou gravado: só assim "não gravou" se distingue de "gravou o mesmo valor duas vezes".

Conferido por mutação (gravação de volta dentro de _update_score; sem gravação no GAME_OVER; sem gravação ao ir para segundo plano; gravação presa ao estado JOGANDO; gravação incondicional, ignorando a marca de pendente; marca de pendente nunca limpa; sem gravação ao sair do laço): 7 de 7 mortas.

E no renderizador acelerado de verdade (fora do dummy): a sequência inteira conferida numa partida real — pontuou 1, recorde subiu para 1 na memória, marca suja, 0 no disco durante JOGANDO, e 1 no disco depois do GAME_OVER.

Bloco G — SDL e empacotamento

  • [x] 61. Hints do SDL, filtro de eventos e inicialização do mixer SDL_HINT_RENDER_SCALE_QUALITY=0 e SDL_HINT_RENDER_BATCHING=1 antes de criar o renderizador. pygame.event.set_blocked para tudo que input.py não consome, em especial FINGERMOTION e MOUSEMOTION. pygame.mixer.pre_init(...) antes de pygame.init(), eliminando a dupla inicialização do mixer da v2. Testes: os tipos consumidos por input.py continuam permitidos e FINGERMOTION fica bloqueado; o mixer é inicializado uma única vez. (R26.6, R26.7, R27.6)

Os dois hints ficam em render.create(), não no Game. É lá que o renderizador nasce, e o SDL lê os dois em SDL_CreateRenderer — um hint definido depois não tem efeito nenhum. SDL_HINT_RENDER_SCALE_QUALITY já estava no lugar desde a task 49; entrou ao lado dele o SDL_HINT_RENDER_BATCHING=1, com o mesmo setdefault: investigar outro valor continua sendo exportar a variável, sem editar código. O nome de ambiente é SDL_RENDER_BATCHING, sem o HINT_ — a macro em C não é a string que SDL_GetHint procura, a mesma pegadinha já documentada em viewport.ENV_ORIENTATION.

O filtro é lista de permissão, e é a mesma lista que o poll lê. CONSUMED_EVENTS deriva de FOCUS_LOST_EVENTS e enumera os outros oito tipos; configure_event_filter() faz set_blocked(None) e libera só ela. Bloquear nominalmente FINGERMOTION e MOUSEMOTION resolveria o caso conhecido de hoje e deixaria o de amanhã passar — JOYAXISMOTION, por exemplo, que um gamepad conectado emite continuamente e que o poll nunca leu. Uma lista só para as duas coisas é deliberado: um tipo tratado no poll mas esquecido na lista seria filtrado antes de chegar lá, e o bug apareceria como uma ação que simplesmente parou de funcionar.

Bloquear é mais forte que ignorar, e isso é verificável. pygame.event.post de um tipo bloqueado devolve False e o evento não entra na fila — não vira objeto Python, não é percorrido pelo laço do poll, não é descartado. É o que R27.6 pede, e é medível: no Android o FINGERMOTION chega na taxa do digitalizador (120–240 Hz), três a quatro por frame enquanto o dedo está na tela.

O filtro é global e permanente, como o gc.freeze() da task 59 — e cobrado da mesma forma. Um fixture de conftest.py devolve set_allowed(None) depois de cada teste. Sem ele, o primeiro Game() da suíte passaria a filtrar eventos para todos os testes seguintes, e um teste futuro que precisasse postar um tipo fora da lista falharia por um motivo sem relação nenhuma com ele.

A dupla inicialização do mixer da v2 não era só desperdício: ela não funcionava. Ao investigar, o pygame.mixer.init(...) de SoundManager — chamado com o mixer já de pé por pygame.init() — é no-op: a documentação do pygame diz que os parâmetros de reprodução não mudam sem um mixer.quit() antes, e foi confirmado aqui (pygame.init() → (44100, -16, 2); mixer.init(channels=1) em seguida → (44100, -16, 2), inalterado). Ou seja, o ANDROID_MIXER_BUFFER = 1024 da v2, escrito justamente contra a crepitação no Android (R14.6), nunca chegou ao dispositivo — o mixer vivia com o buffer padrão de 512. Trocar init por pre_init antes do pygame.init() é o que faz esse parâmetro valer pela primeira vez. O ganho de tempo de abertura era o motivo declarado; a correção do buffer é o que realmente estava em jogo.

SoundManager passa a assumir o mixer que já está de pé. Só inicializa por conta própria se pygame.mixer.get_init() for None — o caso de quem constrói o gerenciador fora do jogo (um teste, um script) e o caso de o pygame.init() não ter conseguido abrir o dispositivo. No segundo, a tentativa aqui também falha e o jogo segue mudo, como desde a v1 (R8.4). Os parâmetros saem de um único mixer_params(), usado pelas duas chamadas, tipado como TypedDict para que o desempacotamento seja conferido pelo ty contra a assinatura real do pygame (que tem também parâmetros de outros tipos).

O formato negociado não mudou. Neste desktop o dispositivo devolve (44100, -16, 2) antes e depois — o AUDIO_ALLOW_CHANNELS_CHANGE que o pygame passa por padrão converte o pedido mono em estéreo, e fazia isso na v2 também. O que mudou foi quem decide o buffer, não o formato.

Testes (13 novos: 6 em tests/test_input.py, 5 reescritos/novos em tests/test_sounds.py, 3 em tests/test_render.py; 447 no total). Os dois eventos de movimento bloqueados; todos os CONSUMED_EVENTS liberados; oito tipos que ninguém lê (KEYUP, FINGERUP, JOYAXISMOTION, TEXTINPUT, …) bloqueados, junto da afirmação de que os de movimento não estão na lista de permissão; post de um bloqueado devolvendo False e não produzindo ação, com âncora de que o mesmo ponto como FINGERDOWN ainda voa; e o Game() instalando o filtro por conta própria. No mixer: os parâmetros por plataforma; pre_init levando exatamente os mesmos parâmetros do init; um mixer já de pé não sendo inicializado de novo; um mixer ausente sendo inicializado com os parâmetros certos; a falha de dispositivo deixando o jogo jogável e mudo; e a ordem — pre_init antes de pygame.init(), que é a única coisa que faz a chamada ter efeito. Nos hints: agrupamento pedido, ambiente respeitado, e os dois valores conferidos dentro da criação de video.Renderer, e não no ambiente depois que tudo acabou.

Conferido por mutação (filtro sem set_blocked(None); filtro como lista de bloqueio nominal; CONSUMED_EVENTS sem FINGERDOWN, sem os eventos de ciclo de vida, e admitindo FINGERMOTION; Game sem chamar o filtro; pre_init depois do pygame.init() e pre_init ausente; SoundManager inicializando sempre e nunca inicializando; mixer_params sem o buffer do Android; hint de agrupamento ausente e sobrescrevendo o ambiente; hints saindo de create()): 14 de 14 mortas. Uma décima quinta — mover os dois hints para depois de _open_sdl_window, mantendo-os antes de video.Renderer — sobreviveu, e está registrada aqui como equivalente: nenhum dos dois é lido na criação da janela, os dois são lidos em SDL_CreateRenderer, que é exatamente o ponto que o teste afirma.

E no renderizador acelerado de verdade (fora do dummy, backend: gpu-accelerated): 240 frames em JOGANDO sem exceção, mixer.init chamado zero vezes depois do pygame.init(), os dois eventos de movimento bloqueados, todos os consumidos liberados, e post(MOUSEMOTION) recusado pela fila. ruff check, ruff format --check e ty check sem violações.

  • [x] 62. buildozer.spec da v3 android.archs sem x86_64, android.presplash_color, version = 0.3.0, e o package.name já trocado na task 42. Validar como INI bem formado. Registrar no design que o ganho é de tamanho e tempo de build, não de FPS. (R17.1, R22.2)

A cor da abertura saiu do próprio jogo. android.presplash_color = #87CEEB é o sky_top do Overworld (biome.py), o primeiro pixel que o jogador vê quando o primeiro frame chega. Sem a chave o buildozer usa preto, e a espera entre o toque no ícone e o jogo aparece como uma tela apagada; com ela, aparece como o jogo abrindo. Não encurta o tempo, encurta a percepção — e o teste amarra o valor ao BIOMES[0].sky_top, e não ao literal, para que trocar o céu do Overworld sem trocar a abertura seja um teste vermelho em vez de uma incoerência silenciosa.

x86_64 sai, e o registro honesto de que isso não muda FPS já estava no design (seção 40, desde a task 47) — aqui ele ganhou uma linha a mais: a redução de tamanho só pode ser medida num build real, então o número entra na task 73 com os demais. O que se pode afirmar sem build é a estrutura: o pacote é único, as três ABIs iam juntas para todo aparelho, e nenhum celular real usava a terceira. Nenhum aparelho fica de fora — o Android escolhe a melhor ABI entre as embarcadas, e as duas que ficam cobrem de 32 bits na API 21 a qualquer aparelho corrente.

O teste faz mais do que a task pediu, e o motivo é o arquivo. buildozer.spec não é código, não roda em lugar nenhum e só é lido semanas depois, na máquina que constrói o APK. Um erro aqui não quebra nada localmente: aparece como um build que falha no CI ou, pior, como um APK correto que sai com a versão, o nome ou as arquiteturas erradas. Então, junto da validação de INI bem formado que R22.2 pede, entraram as chaves cuja perda seria cara e silenciosa: o pino p4a.branch = v2024.01.21 (o achado da v2 que mantém o build de pé), a faixa de API, a trava de orientação que precisa concordar com viewport.lock_portrait_orientation(), e a exclusão de tests/ e specs/ do pacote.

Testes (tests/test_packaging.py, novo — 10 casos; 457 no total). INI bem formado com exatamente as duas seções; título, package.name e domínio; versão 0.3.0; as arquiteturas sem x86_64; e as duas que restaram presentes, que é o outro lado do corte; a cor da abertura como hexadecimal válido e igual ao céu do Overworld; orientação e tela cheia; a faixa de API; o pino do p4a e o caminho das receitas locais; e as pastas que não podem entrar no APK.

Conferido por mutação (x86_64 de volta; arm64-v8a removida junto; versão de volta à 0.2.0; presplash preto, ausente, e com nome de cor em vez de hexadecimal; package.name com o nome antigo; INI mal formado; pino do p4a apagado; tests/ deixando de ser excluída; orientação livre): 11 de 11 mortas.

O que esta task não pode verificar. Que o APK realmente encolheu, que a tela de abertura aparece azul e que o aplicativo instala nas duas ABIs — tudo isso exige o build e o aparelho, e está na task 72.

Bloco H — Loop e adaptação

  • [x] 63. Timestep fixo com acumulador Game.run passa a acumular o tempo real de clock.tick() e a rodar update() em passos fixos de 1/60 s, com clamp do delta e teto de passos por frame. update() e as constantes ficam intactas. Testes: um frame de 16 ms produz 1 passo; um de 33 ms produz 2; um de 5000 ms produz no máximo o teto e zera o acumulador; a 60 FPS o estado após N frames é idêntico ao da implementação por frame da v2. (R28.1, R28.2, R28.3, R28.4)

Como no design, e num método próprio. Game.simulate(frame_ms) recebe o tempo real, devolve quantos passos rodou, e o run() fica com três linhas: pegar o tempo do relógio, simular, desenhar. O método existe para ser chamado direto pelo teste — afirmar sobre "quantos passos comprou este frame" através do laço inteiro exigiria um relógio falso a cada asserção, e o que se quer verificar não tem nada a ver com o laço.

Os 16 e os 33 ms do enunciado são arredondamentos, e a diferença importa. Um passo dura 16,67 ms; um frame de 16 ms inteiros não compra passo nenhum — compra 0,96 de um. É exatamente o que o acumulador existe para resolver: Clock.tick devolve inteiros, a 60 FPS reais alternando entre 16 e 17, e descartar a diferença faria o jogo correr 4% devagar num aparelho que não perdeu um quadro sequer. Então os testes afirmam sobre o que é verdade: um frame de duração de passo compra 1; um frame de 30 FPS (33,3 ms) compra 2; e 60 frames de 16 ms inteiros compram 57 passos — os 960 ms de tempo real que eles somam, nem um a mais nem um a menos —, com a sobra de 10 ms guardada e não jogada fora.

MAX_FRAME_MS está no código, cumpre R28.3, e hoje não faz nada — registrado por honestidade. A mutação que remove o corte de 250 ms sobreviveu, e ao investigar o motivo ele é sólido: com MAX_STEPS = 5 e passos de 16,67 ms, o teto de passos morde em 83 ms, muito antes dos 250. Qualquer frame acima disso bate no teto, e quem bate no teto tem o acumulador zerado — de modo que 250 ms, 5 s ou um minuto produzem exatamente o mesmo resultado. Quem cumpre R28.3 hoje é o teto de passos, não o corte de tempo. O corte ficou porque é a rede que passa a valer se MAX_STEPS subir, e entrou no lugar dele um teste que afirma a relação (MAX_STEPS * STEP_MS < MAX_FRAME_MS): assim, o dia em que o teto de passos ultrapassar o corte de tempo é um teste vermelho, e não duas constantes se contradizendo em silêncio. A mutação que eleva MAX_STEPS para 20 morre nesse teste.

A equivalência com a v2 é afirmada sobre uma partida de verdade, não sobre três frames. Duas partidas nascem da mesma semente, uma avança por simulate(STEP_MS) e a outra por update() direto, e as duas são jogadas por um piloto automático que voa quando a abelha está abaixo da abertura da próxima coluna. São 2.000 passos — o bastante para atravessar 20 pontos, spawns, descartes e a troca de bioma para a Cave —, e ao fim os dois estados são idênticos em 18 grandezas: física, hitbox, fase da animação da asa, temporizadores de fade e de faixa de bioma, deriva do chão, das duas camadas de parallax e dos mobs. Um roteiro cego (flapar a cada N passos) morria na primeira coluna e não teria exercitado nada disso. O piloto é função do estado, então os dois laços só tomam a mesma decisão enquanto estiverem mesmo no mesmo estado — se um divergir, o roteiro diverge junto e a comparação final denuncia.

Dois defeitos apareceram nesse caminho, e os dois eram meus. O primeiro: a primeira versão do teste semeava o random global e não o devolvia, e um test_bare_tv_remote_reaches_every_state que não tem nada a ver com isso passou a falhar. O segundo é o motivo pelo qual ele falhou: aquele teste forçava o fim de jogo colocando a abelha em y = 0 contra a coluna, e a metade de cima da coluna tem altura gap_y - 80 — uma abertura sorteada bem no alto a deixa com altura quase zero, e a abelha passa sem colidir. Era um teste com ~4% de chance de falhar conforme o sorteio, latente desde a v1 e revelado por acidente. A colisão passou a ser contra o chão, que é incondicional, e o teste novo devolve o estado do sorteio com um fixture. A lição fica registrada: semear o random global num teste é um efeito colateral sobre a suíte inteira, como o gc.freeze() da task 59 e o filtro de eventos da 61.

A instrumentação mede o frame, não o passo. Com BLOCKY_PERF=1, profiler.begin()/end_update() envolve o simulate inteiro: dois passos num frame de 30 FPS custam o dobro de um, e é isso que o jogador sente. Medir um passo só diria que a simulação está barata justamente quando ela está rodando duas vezes.

Testes (tests/test_game.py — 12 novos, 1 endurecido; 472 no total). Um frame de duração de passo produz 1; um de 30 FPS produz 2; a sobra de frames de 16 ms é carregada e 60 deles somam 57 passos com 10 ms guardados; um travamento de 5 s bate o teto e zera o acumulador; o frame seguinte volta ao ritmo normal; dez frames seguidos no limite continuam batendo o teto sem dívida acumulada; cinco durações diferentes — de 84 ms a um minuto — todas produzindo o mesmo teto (a propriedade de R28.3 dita sobre qualquer duração); a relação entre os dois limites; a equivalência com a v2 ao longo de 2.000 passos; o laço rodando 4 passos em 2 quadros desenhados e ainda pedindo 60 FPS ao relógio; e o profiler medindo o frame inteiro.

Conferido por mutação (delta sem corte; sem teto de passos; acumulador não zerado ao bater o teto; acumulador reiniciado a cada frame; acumulador zerado a cada passo em vez de decrementado; > no lugar de >=; laço de volta a um update por frame; desenho dentro do laço de passos; tick sem limite de FPS; caminho com profiler simulando um passo só; STEP_MS de 30 passos por segundo; MAX_STEPS elevado acima do corte de tempo): 11 de 12 mortas. A sobrevivente é o corte de tempo, pelo motivo explicado acima — não é lacuna de teste, é comportamento hoje inalcançável, e o teste da relação entre as constantes é o que guarda o flanco.

E no laço real, com renderizador acelerado (fora do dummy, backend: gpu-accelerated): 300 quadros desenhados em 5,10 s a 58,8 FPS, com 301 passos de simulação — 1,003 por quadro, 59,0 por segundo — e o acumulador terminando em 6,3 ms, abaixo de um passo. O 1% que falta para 60 não é do acumulador: é do Clock.tick, que devolve milissegundos inteiros e trunca ~0,25 ms por quadro. ruff check, ruff format --check e ty check sem violações.

  • [x] 64. Qualidade adaptativa (src/quality.py) Três níveis (ALTO/MÉDIO/BAIXO) conforme a seção 38, com medição em janela deslizante apenas em JOGANDO, histerese na troca e persistência via storage.save_dir(). Nada que afete regra muda entre níveis. Testes: FPS sustentado baixo desce de nível; um vale isolado não desce; a subida exige margem e janela maior; velocidade de coluna, gap, hitbox e pontuação são idênticos nos três níveis; arquivo corrompido resulta em nível ALTO. (R29.1, R29.2, R29.3, R29.4, R29.5, R29.6)

A histerese é escrita como duas janelas, não como dois contadores. A curta (120 quadros, ~2 s) só pode descer; a longa (300, ~5 s), que a contém, também pode subir. Quando a janela curta fecha sem queda, ela não reinicia — continua acumulando até a longa, e é isso que faz a subida exigir cinco segundos ininterruptos enquanto a queda responde em dois. Os limiares são 50 FPS para descer e 58 para subir, e a faixa morta entre eles é a histerese propriamente dita: entre 50 e 58 nada acontece.

O cenário que a histerese existe para impedir, dito como teste. Um aparelho que roda a 45 FPS no ALTO e a 55 no MÉDIO — o desligamento da decoração ajuda, mas não o bastante. 55 está acima do limiar de queda: se a subida usasse o mesmo número, ele voltaria ao ALTO, cairia para 45, desceria de novo, e alternaria a cada poucos segundos, que é visualmente pior do que ficar no nível de baixo. Com a margem, ele desce uma vez e assenta — verificado ao longo de 12 janelas seguidas. E vale registrar o limite honesto: nenhuma histerese por limiar impede a oscilação de um aparelho cujo ganho ao descer seja maior que a distância entre os limiares. O que a margem de 8 FPS faz é cobrir o caso comum, não todos.

Fora de JOGANDO a janela é descartada, não pausada. Um quadro em PRONTO ou GAME_OVER zera a medição em curso. Congelá-la em vez de descartá-la somaria os quadros bons de antes da pausa aos de depois, com o tempo parado no meio — e o aparelho seria punido por o jogador ter trocado de aplicativo. R29.1 diz "enquanto o jogo está em JOGANDO", e essa é a leitura estrita.

A gravação sai do quadro, pelo caminho que a task 60 já tinha aberto. A troca de nível marca dirty, e Game._flush_to_disk — que agora descarrega os dois arquivos — grava no fim da partida, na ida para segundo plano e na saída do laço. R27.5 vale para este arquivo também, e aqui pesa mais: a troca de nível acontece justamente no aparelho que já está com dificuldade, e uma escrita síncrona ali cairia no pior momento possível. O teste que afirma isso observa de dentro do laço, um registro por quadro: depois que run() retorna, a saída ordenada já gravou o pendente, e "não gravou durante o jogo" seria indistinguível de "nunca gravou".

O nível é gravado pelo nome, não pelo número. {"level": "MEDIO"}. Gravar o inteiro do IntEnum amarraria o arquivo à ordem da enumeração — inserir um nível no meio, um dia, reinterpretaria em silêncio o arquivo de quem já jogava. A IntEnum fica, porque a ordem é usada em código (subir e descer são comparações); ela só não atravessa a fronteira do disco.

quality.py não conhece o jogo, e o jogo não conhece as camadas. O módulo expõe uma tabela de Settings e quem lê é o Game, que passa far=/near= ao decor.draw e decide se chama mobs.draw_*. decor.py e mobs.py continuam sem saber que existem níveis — a mesma dependência de mão única que torna R25.4 verificável. A deriva de todos eles continua sendo atualizada mesmo com a camada desligada, para que voltar ao nível de cima não produza um salto no cenário.

R29.3 é o requisito que este módulo inteiro não pode violar, e é o teste mais caro do arquivo. Duas partidas de 200 passos, mesma semente, mesmo roteiro, uma em cada nível, comparadas em posição, velocidade, hitbox, x e altura das duas metades de cada coluna, abertura e pontuação. Sem isso, um aparelho fraco jogaria um jogo mais fácil (ou mais difícil) e a tabela de recordes deixaria de significar a mesma coisa — que é exatamente a comparabilidade que R24 garante. A única grandeza numérica que muda é a contagem de partículas, e elas nascem depois da colisão, sem colidir com nada.

Testes (tests/test_quality.py, novo — 42 casos; 514 no total). Queda por taxa sustentada; a janela inteira sendo necessária; um travamento isolado de 300 ms não derrubando nada (a média cai para ~52, acima do limiar); queda de um nível por vez; o piso; a subida exigindo a janela longa e a margem; o aparelho no limite assentando; o teto; quadros fora de JOGANDO não medindo nada e a pausa descartando a janela; a tabela dos três níveis e o render_fps que só cai no último; as regras idênticas nos três níveis; a contagem de partículas; ida e volta ao disco; arquivo ausente e seis formas de arquivo corrompido; falha de escrita engolida; o arquivo ao lado do recorde e gravado pelo nome; a decoração sumindo nível a nível em duas telas — a larga, que tem faixa lateral, e a de celular, que tem faixa de céu, porque cada uma exercita um dos dois desenhos de mob; o relógio pedindo 30 quadros no nível BAIXO; a medição de ponta a ponta pelo laço real, ligada em JOGANDO e desligada em PRONTO; e os três momentos de gravação.

Conferido por mutação (subida com o limiar de queda; subida na janela curta; queda sem piso; queda por um quadro só; medição fora de JOGANDO; janela não reiniciada; troca sem marcar pendente; nível gravado por número; arquivo corrompido caindo para o mínimo; MÉDIO desligando também a camada próxima; BAIXO sem baixar o render_fps; Game ignorando o nível gravado; laço sem alimentar a medição; medição alimentada em qualquer estado; tick com FPS fixo; mobs do céu e mobs das laterais desenhados em todo nível; parallax inteiro em todo nível; nível nunca gravado; nível gravado sempre; partículas fixas em 16; camada distante desenhada mesmo desligada): 23 de 23 mortas. Duas delas só depois de o teste de desenho passar a rodar nas duas telas: num canvas 960×720 a faixa de céu tem altura zero, então remover a guarda do draw_sky não mudava um pixel — e a mutação sobrevivia.

E no renderizador acelerado de verdade (fora do dummy, backend: gpu-accelerated), 200 quadros em cada nível: ALTO 17,18 ms/quadro, MÉDIO 17,11, BAIXO 33,78 — o último exatamente o dobro, que é o render_fps = 30 valendo. E a demonstração de que a simulação não desacelerou junto: nos mesmos 200 quadros, a partida do nível BAIXO marcou 3 pontos contra 1 dos outros dois, porque cada quadro seu comprou dois passos de simulação. Menos imagem, mesmo jogo. A persistência conferida de ponta a ponta em quality.json, ao lado do highscore.json (e adicionado ao .gitignore pelo mesmo motivo dele: é medição local, não configuração do projeto). ruff check, ruff format --check e ty check sem violações.

Bloco I — Documentação e rigor SDD

  • [x] 65. Docstrings em todo o projeto e gate no ruff Ativar as regras D (pydocstyle, convenção google) no pyproject.toml e preencher as docstrings faltantes em src/, scripts/, main.py e conftest.py, em português, respeitando line-length = 110. O CI já roda ruff check, então o gate entra sem infraestrutura nova. (R31.1, R31.2, R31.3)

O ponto de partida já tinha 75 dos 154 erros resolvíveis por --fix (D209, fecho de aspas de docstring multilinha numa linha própria) — boa parte do código já vinha com docstring de fato, só não na forma que pydocstyle exige; os 79 restantes eram classes, métodos, __init__ e funções realmente sem nenhuma. Todos preenchidos à mão, uma frase de resumo mais uma segunda linha em branco quando havia contexto adicional (D205 cobra exatamente essa separação).

O gate ficou restrito a src/, scripts/, main.py e conftest.py, e não a tests/. A task pede docstring nesses quatro lugares — não em tests/, onde o nome da função já é a documentação (test_flap_aplica_impulso_e_ignora_gravidade_no_mesmo_quadro, por exemplo) e uma exigência de docstring só produziria ruído repetitivo em ~500 testes. ruff check . roda no repo inteiro (é o que o CI chama), então a exclusão foi codificada em [tool.ruff.lint.per-file-ignores] ("tests/*.py" = ["D"]), e não por convenção informal.

p4a-recipes/ saiu do escopo do ruff, não só do de docstring. É código copiado de um PR upstream do python-for-android (tasks 27–28), documentado em inglês e já excluído por [tool.ty.src] pelo mesmo motivo — não é código deste projeto. extend-exclude ganhou a mesma entrada, para que o gate novo não force uma tradução ou reformatação de texto de terceiros.

Suíte completa (514 testes), ruff check, ruff format --check e ty check sem violações, confirmados depois do preenchimento.

  • [x] 66. Site de documentação (MkDocs Material + mkdocstrings) mkdocs.yml publicando a página de apresentação, a API extraída das docstrings de src/ via mkdocstrings[python], e os specs de v1/v2/v3 lado a lado. Workflow .github/workflows/docs.yml para GitHub Pages. Dependências apenas no grupo dev. Validar com uv run mkdocs build --strict. (R31.4, R31.5)

Nenhum conteúdo é duplicado — docs/ é só um esqueleto de inclusões. Cada página de spec (docs/specs/vN/*.md) e a própria página inicial (docs/index.md) são uma linha só, --8<-- "specs/vN/arquivo.md" (pymdownx.snippets, base_path: ["."]), apontando para o arquivo real na raiz do repo. Documentar duas vezes é o problema que a task 41 do design.md já registrava — README e site divergindo silenciosamente — e a inclusão resolve isso por construção: editar specs/v3/design.md já atualiza o site no próximo build, sem lembrar de copiar nada.

As páginas de spec viraram README.md, não index.md. Os documentos reais (specs/v1/README.md, specs/v2/README.md...) já se linkam entre si com caminhos relativos (v1/README.md, ../README.md) — nomear os stubs em docs/ do mesmo jeito faz esses links resolverem de graça dentro do site; com index.md cada um viraria um aviso de link quebrado no --strict.

docs/api.md é uma lista plana de ::: src.modulo, um por módulo de src/, não uma árvore de páginas. Simples de manter (uma linha nova por módulo novo) e evita a complexidade de navegação aninhada que mkdocstrings ofereceria — o site tem 23 módulos, não 200.

Um link real quebrava o --strict: o README.md linkava .github/workflows/release.yml por caminho relativo, que não existe dentro de docs/. Trocado por URL absoluta do GitHub (blob/main/...), a única correção de conteúdo fora do docs/ que esta task fez.

uv run mkdocs build --strict roda limpo (zero warnings; sobram só infos de link relativo entre os README.md das versões, que o mkdocs resolve sozinho). Dependências (mkdocs, mkdocs-material, mkdocstrings[python]) entraram só no grupo dev — uv sync sem --group dev (o caminho do PyInstaller/Buildozer) não as instala, cumprindo R31.5. site/ (gerado) foi ao .gitignore, ao lado de bin//.buildozer/ pelo mesmo motivo: artefato de build, não fonte.

Não verificável neste ambiente: o deploy real do .github/workflows/docs.yml (actions/deploy-pages) exige que a Origem do GitHub Pages do repositório esteja configurada como "GitHub Actions" — uma opção do painel do GitHub, fora do alcance deste ambiente. Validado apenas como YAML bem formado e por leitura cruzada com a documentação oficial das actions upload-pages-artifact/deploy-pages.

  • [x] 67. Matriz de rastreabilidade (specs/v3/traceability.md) Uma linha por critério de aceitação, ligando requisito → seção de design → task → teste(s). (R32.1)

168 critérios (R1.1–R34.5), um por linha, nenhum de sobra e nenhum faltando — conferido por script contra a extração automática dos itens numerados de requirements.md. As colunas de Task vieram principalmente das próprias notas retrospectivas de tasks.md, que desde a task 42 já citam requisito e teste em prosa; a matriz é, em boa parte, essa informação transcrita em linha.

Nem toda linha tem teste — e isso é deliberado, não uma lacuna. Critérios cuja task implementadora ainda está pendente nesta versão (65, 66, 68–73) ficam com a coluna de Testes em branco: citar um teste ali seria inventar uma referência que tests/test_traceability.py (task 68) rejeitaria assim que existir. Critérios que só um aparelho Android real comprova usam o marcador manual, o mesmo vocabulário que tasks.md já usa na seção "Requer aparelho Android real".

Conferido por script, não por leitura. Duas verificações automatizadas rodaram sobre o arquivo: (1) todo RN.M de requirements.md aparece exatamente uma vez na matriz, sem sobra nem falta; (2) toda referência arquivo.py::funcao citada (197 no total) resolve para um def real em tests/. As colunas de Design foram checadas por amostragem contra os cabeçalhos reais (##/###) de design.md — inclusive as decimais (32.4, 21.2), que já eram o formato usado no exemplo original da seção 42 do próprio design.md.

  • [x] 68. Teste que verifica a matriz (tests/test_traceability.py) Parse de requirements.md extraindo todo critério RN.M; falha se algum não estiver na matriz e falha se a matriz citar um teste inexistente. É o que impede o documento de envelhecer em silêncio. (R32.2, R32.3)

A pasta de spec é resolvida em tempo de execução (specs/vN de maior N), não fixada em "v3". O convênio do projeto (CLAUDE.md) é que a versão vigente é sempre a de maior número; fixar o caminho em specs/v3/ faria este teste precisar de edição manual no dia em que specs/v4/ nascer — exatamente o tipo de manutenção esquecível que a task existe para eliminar.

Extração de requirements.md por seção, não por regex global. O arquivo tem uma exceção de formato: R9 ("Requisitos não funcionais") não tem o cabeçalho ### Critérios de aceitação que todas as outras seções têm — a lista numerada vem direto sob o ## R9. Dividir o texto por ## R\d+ primeiro e procurar ^\d+\. dentro de cada fatia cobre os dois formatos sem precisar tratar R9 como caso especial; o total bateu com os 168 critérios que a task 67 já tinha contado por script.

Duas asserções, não uma. Além de "todo critério tem linha" (R32.2), um segundo teste cobre o sentido oposto — uma linha sobrando na matriz sem critério correspondente, rastro de um requisito renumerado ou removido sem atualizar traceability.md. R32.2 não pede isso literalmente, mas é o mesmo tipo de silêncio que a task quer impedir.

Resolução de teste via ast, não import nem regex sobre o nome da função. Fazer ast.parse de cada arquivo de tests/ citado e coletar todo FunctionDef/AsyncFunctionDef (em qualquer nível, não só no módulo) evita importar os módulos de teste — que têm efeito colateral via fixtures do conftest.py — só para checar se um nome existe.

Suíte completa (517 testes, +3 desta task), ruff check, ruff format --check e ty check sem violações. Os 197 arquivo.py::funcao citados na matriz resolvem todos para uma função real; nenhum critério de requirements.md ficou de fora.

  • [x] 69. Cobertura mínima no CI pytest-cov no grupo dev, --cov=src --cov-fail-under=N no pyproject.toml, com N fixado na primeira medição arredondada para baixo e meta declarada de 90%. Adicionar o relatório ao job de CI. (R32.4)

Primeira medição: 96,80% (1725/1782 linhas de src/), N fixado em 96. Já folgado acima da meta declarada de 90% — o número existia para não travar o CI no primeiro commit caso o ponto de partida fosse baixo, e não foi o caso. input.py (79%) e render.py (92%) são os módulos mais furados: o primeiro por ramos de hotplug de joystick sem hardware real para exercitar, o segundo pela cascata de fallback do renderizador acelerado (task 49), cujos ramos de erro exigem simular falha de criação de Window/Renderer do SDL.

Configuração inteira em pyproject.toml ([tool.pytest.ini_options]), não como flag só do CI. addopts = "--cov=src --cov-report=term-missing --cov-fail-under=96" roda em todo uv run pytest, local ou no CI — mesma filosofia de "gate sem infraestrutura nova" já usada pelo ruff (task 65): o job de CI não precisa de um passo novo, só o uv run pytest que já existia agora imprime a tabela de cobertura (--cov-report=term-missing) e falha se cair abaixo de 96%. Renomeado o step do ci.yml para deixar o gate visível no log ("Run tests (with coverage gate, R32.4)").

Suíte completa (517 testes) segue verde com o gate ativo, ruff check, ruff format --check e ty check sem violações. .coverage (o relatório binário que o pytest-cov grava a cada execução) foi para o .gitignore, ao lado de site/: é medição local que muda a cada rodada, não fonte do projeto.

  • [x] 70. Atualizar README.md e checklists Nome novo, faixas decorativas, aceleração por GPU, e instruções de documentação, benchmark e sobreposição de diagnóstico. Checklist de verificação manual da v3 no formato já usado na v2. (R22.1, R23.1, R30.1)

A "checklist no formato já usado na v2" é a seção ## Checklist de verificação da v3 já existente neste arquivo (introduzida na task 67, no mesmo formato de duas listas — desktop/CI e "requer aparelho Android real" — que a v2 usa logo acima, em ## Checklist de verificação da v2). O README.md nunca teve checklist própria em nenhuma versão (conferido em git log -p -- README.md); a "atualização de checklists" desta task é revisar essa seção e marcar [x] o que já tem teste passando, não criar uma nova.

11 itens da checklist da v3 estavam com teste já verde havia várias tasks, só sem o [x] marcado — tests/test_mobs.py, tests/test_input.py (faixas decorativas e mudo), tests/test_render.py (cascata e zero alocação), tests/test_alloc.py, tests/test_decor.py e tests/test_game.py (escrita em disco fora de JOGANDO) já cobriam R25.4, R25.6, R26.2–R26.3, R27.2–R27.3, R27.5, R34.2–R34.5 desde as tasks 49–61; e o nome ("Blocky Bee" em config.TITLE, BlockyBee.spec, buildozer.spec, release.yml) e a integridade de specs/v1//specs/v2/ (R22.1, R22.4) só precisavam de inspeção, já verdadeira desde a task 42. Rodar a suíte de novo (pytest tests/test_mobs.py tests/test_input.py tests/test_render.py tests/test_game.py tests/test_alloc.py tests/test_decor.py) confirmou os 219 testes envolvidos passando antes de marcar.

README.md ganhou três coisas que não existiam desde a v1/v2: uma seção Desempenho (aceleração por GPU e a cascata de fallback, BLOCKY_PERF=1 para a sobreposição de diagnóstico, e uv run scripts/benchmark.py — os três comandos testados de verdade neste ambiente antes de documentar); uma seção Documentação (uv run mkdocs serve/build --strict, dependências restritas ao grupo dev); e a árvore de Estrutura, que ainda listava os 12 módulos da v1/v2 e ficou muda sobre os 11 que a v3 acrescentou (viewport.py, render.py, bands.py, mobs.py, quality.py, perf.py, storage.py, assets.py, scale.py, pixelfont.py, além de scripts/ e docs/).

A introdução ganhou um parágrafo sobre faixas decorativas e GPU, e um link para a seção da task 71 (#sobre-este-projeto-e-o-método-sdd) — a âncora não existe até aquela task rodar; mkdocs build --strict confirma isso como INFO (não como erro, porque o link é interno ao Markdown puro, fora do escopo do --strict do mkdocs), e a lacuna fecha na task seguinte.

Suíte completa (517 testes), ruff check e ruff format --check sem violações. Nenhum arquivo de src/, scripts/ ou tests/ mudou nesta task — só documentação — então ty check não tinha o que checar de novo.

  • [x] 71. Motivação e método SDD no README.md Três blocos, reaproveitados como página inicial do site: por que este projeto existe (estudar SDD na prática e fazer um jogo para o filho, que gosta de jogos e de Minecraft — o que também explica a temática e o CREDITS que está no jogo desde a v1); o que é SDD, ancorado nos artefatos deste repositório; e um prompt de exemplo pronto para uso, combinando voz de Product Owner (objetivo, público, user stories, critérios EARS, fora de escopo) e de Tech Lead com prática em Python (stack, uv, estrutura, ruff/ty/pytest como gate, restrições, e o protocolo de gerar os três documentos antes de codar e implementar uma task por vez), com uma linha explicando o porquê de cada parte e genérico o bastante para servir a outro projeto. (R33.1, R33.2, R33.3, R33.4, R33.5)

A seção nova (## Sobre este projeto e o método SDD) vira a página inicial do site de graça — docs/index.md já era só --8<-- "README.md" (task 66), então o README.md inteiro, com o bloco novo no fim, aparece na home do MkDocs sem tocar em docs/.

O link da introdução para a seção precisou do slug sem acento (#sobre-este-projeto-e-o-metodo-sdd), não #...-método-sdd. O mkdocs/Python-Markdown remove diacríticos ao gerar o id do cabeçalho; um link com o "é" acentuado passava no ruff/pytest (não é código) mas quebraria silenciosamente no site. Pego só porque uv run mkdocs build --strict foi rodado depois de escrever o link — sem isso o erro não apareceria em nenhum teste automatizado deste projeto (nenhuma task pede lint de link interno de Markdown fora do mkdocs --strict).

O "o que é SDD" cita artefatos reais do próprio repositório, com link relativo para cada um — specs/v3/requirements.md, design.md, tasks.md, traceability.md e specs/README.md — em vez de descrever o método em abstrato. É o mesmo argumento que já justificou a escolha do MkDocs na task 66: numa spec que governa código, apontar para o artefato de verdade é mais convincente que resumi-lo.

O prompt de exemplo não menciona jogo, Pygame nem Blocky Bee em lugar nenhum — só "este projeto" e placeholders de domínio — para cumprir R33.5 (genérico o bastante para outro ponto de partida) sem precisar de uma segunda versão "genérica" do texto.

Nenhum arquivo de src/, scripts/ ou tests/ mudou — só README.md — então a suíte de 517 testes, ruff check, ruff format --check e ty check seguem no mesmo estado da task 70; a validação desta task foi uv run mkdocs build --strict (limpo, sem ERROR/WARNING novos) confirmando que o link resolve.

Bloco J — Fechamento

  • [x] 72. Build do APK e validação em aparelho real Gerar o APK pelo caminho já documentado, instalar, e conferir: nenhuma barra preta em lado nenhum; o app não gira ao virar o aparelho; AndroidManifest.xml com android:screenOrientation="portrait"; a sobreposição com BLOCKY_PERF=1 mostrando FPS sustentado e backend acelerado; a dificuldade igual à do desktop; faixas e mobs visíveis sem interferir no jogo. (R23.1, R23.3, R23.5, R25.2, R25.3, R26.1, R27.1)

Docker ficou disponível de novo nesta sessão (a mesma ressalva da seção 25 do design: a disponibilidade varia entre sessões, então isso foi checado com docker ps antes de assumir qualquer coisa). Rodado o mesmo comando já documentado no README.md (docker run kivy/buildozer android debug, com MSYS_NO_PATHCONV=1 porque este ambiente é Git Bash/MSYS no Windows) para gerar o APK real com o código da v3 — a primeira vez que o caminho de render acelerado por GPU (task 49) e o buildozer.spec/ícones da v3 (tasks 36-42) passam pela cadeia de build Android de verdade, não só pela suíte pytest.

Bug real encontrado na primeira tentativa, da mesma classe já documentada na task 28: a build falhou com Available Android APIs are () — Requested API target 34 is not available. Causa: .buildozer/state.db (arquivo local, .gitignore, cache de builds anteriores desta máquina) guardava a chave "android:sdk_installation": ["34", "21", "25b", ...], marcando o SDK como já instalado; mas o $HOME/.buildozer montado nesta sessão (~/.buildozer, mapeado para /home/user/.buildozer no container) estava vazio — sessão nova, cache do host limpo. O buildozer confiou no marcador do projeto em vez de checar o conteúdo real do cache montado, pulou a etapa de instalar os pacotes do SDK (platforms;android-34, platform-tools) e só baixou o NDK, deixando o android-sdk/ sem platforms/ nenhum. Fix: apagar .buildozer/state.db (confirmado gitignored antes de mexer) para forçar o buildozer a reconferir e reinstalar o SDK do zero na tentativa seguinte — mesmo raciocínio do fix da task 28 ("cache limpo para forçar reclone correto"), só que aplicado à trilha do SDK, não do hostpython3.

Segunda tentativa: BUILD SUCCESSFUL, ~4h20 de ponta a ponta com cache frio (download do SDK/NDK, compilação de hostpython3 para as duas ABIs, e as 16 receitas — incluindo pygame-ce e as duas receitas locais do projeto, jpeg e pygame-ce — antes do empacotamento via Gradle 8.0.2). Gerado bin/blockybee-0.3.0-armeabi-v7a_arm64-v8a-debug.apk (38,7 MB, contra os 62 MB do blockybee-0.2.0 antigo — a v3 não builda mais x86_64, android.archs no buildozer.spec lista só armeabi-v7a, arm64-v8a, redução deliberada de escopo já existente antes desta task, não uma regressão dela). Inspecionado o .apk (é um zip): as bibliotecas nativas de ambas as ABIs estão presentes (lib/arm64-v8a/libpython3.11.so, libSDL2*.so, libpybundle.so — a saída da receita pygame-ce — e o espelho em lib/armeabi-v7a/), e a linha de comando do python-for-android (capturada via docker top durante o build) confirma --orientation portrait --package com.douglaspands.blockybee --version 0.3.0, batendo com o buildozer.spec.

Não verificável neste ambiente (mesmo limite da seção 25 do design, sem mudança nesta versão): instalação e uso em aparelho físico. Sem Android real nem emulador aqui, os itens que só um toque na tela ou um adb/olho humano confirmam — ausência de barra preta em qualquer borda, travamento de fato da orientação ao girar o aparelho, o backend acelerado (não o de superfície) em uso na sobreposição BLOCKY_PERF=1, FPS sustentado, e a dificuldade percebida igual à do desktop — continuam como itens [ ] na seção "Requer aparelho Android real" abaixo, pendentes de instalação manual do .apk gerado por esta task pelo dono do projeto. android:screenOrientation="portrait" no AndroidManifest.xml compilado não foi lido via aapt dump badging (não disponível fora do container) — a evidência aqui é indireta (flag --orientation portrait na linha de comando do build, mais o buildozer.spec já validado como INI bem formado desde a task 27), não uma leitura direta do binário.

Nenhum arquivo de src/, scripts/ ou tests/ mudou — só o .buildozer/state.db local (cache, gitignored, não versionado) e a geração do .apk em bin/ (também gitignored) — então a suíte de 522 testes, ruff check, ruff format --check e ty check seguem no mesmo estado da task 71.

  • [x] 73. Registrar os números medidos Preencher o bloco BASELINE da seção 30 do design.md com antes/depois e anotar em cada task o que foi validado aqui e o que dependeu de aparelho real, no estilo já usado na v2. (R30.5)

"Resultado final da v3" preenchido na seção 30 do design.md, com o mesmo comando e a mesma máquina do baseline (uv run python scripts/benchmark.py --frames 300, driver dummy), diferindo só no canvas: 960×720 em vez de 480×720, porque a v3 abre a janela de desktop mais larga que a área jogável de propósito (R23.7). Números: draw calls/frame de 21/17/17/18/34 (PRONTO/overworld/cave/nether/GAME_OVER) contra ~96 do baseline da v2 — queda de ~70-80% apesar da área desenhada ter dobrado; memória transitória de 0,6 KB/frame contra ~3,0 KB (~80% a menos). Esses dois números batem exatamente com o que cada task de otimização (50, 53-56) já vinha registrando isoladamente desde a task 50 — esta task é a primeira vez que ficam reunidos lado a lado com o baseline, no lugar (design.md seção 30) que R30.5 pede.

O p50/p95 em milissegundos não caiu — subiu, de ~3,4 ms para ~9,6-11,2 ms — e isso é esperado, não uma regressão. Sob SDL_VIDEODRIVER=dummy o renderizador acelerado não pode ser criado (Couldn't find matching render driver), então o benchmark mede o segundo nível da cascata da task 49 (_sdl2.Renderer não acelerado, ainda em software) desenhando uma área com o dobro de largura do baseline. O ganho real desta versão está nas draw calls (o que de fato vira uma chamada à GPU no caminho acelerado) e nas alocações — não no ms bruto medido num driver headless sem GPU nenhuma. Documentado na íntegra na seção 30, para que quem ler a tabela não confunda os dois efeitos.

O que ficou pendente de aparelho real (mesmo motivo da seção 25 do design, sem mudança nesta versão): o único número que R27.1 realmente pede — 60 FPS sustentado — e a confirmação de que o backend acelerado está de fato em uso fora do driver dummy. Essa parte é objeto da task 72; a linha "Ganho medido contra o baseline (R30.5)" do checklist abaixo passa a [x] porque a métrica que essa linha cobre (valores registrados no design) está satisfeita, enquanto os itens da lista "Requer aparelho Android real" permanecem [ ] até validação manual do dono do projeto.

Nenhum arquivo de src/, scripts/ ou tests/ mudou — só specs/v3/design.md — então a suíte de 522 testes, ruff check, ruff format --check e ty check seguem no mesmo estado da task 71; a validação desta task foi uv run mkdocs build --strict (limpo, sem ERROR/WARNING novos) confirmando que a seção editada não quebra o site.

Bloco K — Higiene de Git e GitHub

  • [x] 74. LICENSE (MIT) e metadados LICENSE no root com a licença MIT; pyproject.toml ganha o campo license; README.md ganha uma seção curta explicando que a licença cobre o código, não a marca Minecraft. Cria tests/test_repo_hygiene.py. (R35.1, R35.2)

pyproject.toml não tem [build-system] — o projeto não publica um pacote instalável, só usa uv/PyInstaller/Buildozer diretamente. O campo license = "MIT" (string SPDX, PEP 639) ainda é válido e lido por uv sync sem exigir um backend de build; confirmado rodando uv sync depois da mudança.

O teste do campo license não usa tomllib. tomllib só existe a partir do Python 3.11, e o projeto fixa requires-python >= 3.10 (o ambiente de desenvolvimento roda 3.10.20) — importar tomllib quebraria a suíte na CPython mínima suportada. Em vez de puxar um parser TOML de terceiros só para ler uma linha, o teste isola o bloco [project] por str.split e confere o campo por regex.

O disclaimer de marca no README.md quebrou o primeiro teste por causa da quebra de linha do Markdown — "não" e "a marca" caíram em linhas fonte diferentes (só se juntam depois de renderizado), então "não a marca" in text falhava contra o texto bruto. Trocado por "marca **Minecraft**", que fica inteiro numa linha só.

Suíte completa (520 testes, +3 desta task), ruff check e ruff format --check sem violações.

  • [x] 75. .editorconfig e .gitattributes .editorconfig com regras globais e por tipo de arquivo; .gitattributes normalizando final de linha para LF, com os binários versionados (.png, .ico) marcados explicitamente. (R35.7)

.editorconfig desliga trim_trailing_whitespace para .md. É a única exceção às regras globais: dois espaços no fim de uma linha de Markdown são uma quebra de linha manual (<br>), não sujeira — um editor que os removesse automaticamente mudaria a renderização sem ninguém perceber.

.gitattributes foi validado de verdade, não só por leitura. git add --renormalize . (a forma de aplicar a regra nova a todo o histórico já commitado) foi rodado como checagem: comparado contra HEAD, o único conteúdo que mudou de estado foi exatamente as edições desta sessão (as mesmas 11 linhas de arquivo já esperadas) — nenhuma reescrita de final de linha em massa em arquivo já commitado, então * text=auto eol=lf não conflita com o que já está no repositório. Staging desfeito com git reset depois da checagem — nenhum commit criado por esta task.

Suíte completa (522 testes, +2 desta task), ruff check e ruff format --check sem violações.

  • [x] 76. .pre-commit-config.yaml e o mesmo gate também no CI Hooks espelhando ruff check/ruff format/ty check mais checagens básicas de higiene; pre-commit no grupo dev; novo step em ci.yml rodando os mesmos hooks, para o gate valer mesmo sem instalação local. (R35.5, R35.6)

A primeira rodada de uv run pre-commit run --all-files "corrigiu" 82 arquivos — e isso era esperado, não um bug. O hook mixed-line-ending reescreveu CRLF→LF no diretório de trabalho inteiro, porque o Windows local tem core.autocrlf=true e o .gitattributes novo (task 75) declara eol=lf. Verificado que era inofensivo antes de seguir: git diff --stat contra HEAD continuou mostrando só os 11 arquivos desta sessão — os outros 71 são bytes que core.autocrlf já tratava como equivalentes ao blob armazenado (confirmado fazendo git add num deles e vendo git status não acusar nada). Nenhum commit foi criado por esta verificação; o git add/git reset de teste foi desfeito.

pyyaml entrou no grupo dev, explícito, mesmo já vindo transitivamente pelo mkdocs. Os testes desta task fazem import yaml para validar o .pre-commit-config.yaml e (task 79) o dependabot.yml/ci.yml — depender de uma transitiva não declarada quebraria silenciosamente se o mkdocs um dia deixasse de precisar dela.

ty entra como hook local/language: system, não um repo externo. Diferente do ruff (que tem astral-sh/ruff-pre-commit mantido pela Astral), o ty ainda não tem repositório oficial de pre-commit — uv run ty check . usa a mesma versão pinada em uv.lock que o CI já roda, em vez de abrir uma segunda fonte de verdade isolada.

O CI ganhou um step novo (pre-commit run --all-files) que reexecuta ruff-check/ruff-format por cima dos steps que já existiam. Redundância intencional, documentada na seção 44 do design: os hooks de higiene (trailing-whitespace, check-yaml etc.) não são cobertos por nenhum dos três gates existentes, e um gate que só vale com instalação local lembrada não é gate.

Validado de ponta a ponta: uv run pre-commit install + uv run pre-commit run --all-files limpo (10 hooks, todos verdes) depois da normalização; suíte completa (525 testes, +3 desta task), ruff check, ruff format --check e ty check sem violações.

  • [x] 77. CONTRIBUTING.md Documenta as convenções de branch, commit e tag já em uso informalmente, e aponta para a seção de SDD do README.md e para a instalação do hook de pre-commit. (R35.3)

O link do README.md para CONTRIBUTING.md precisou ser absoluto (URL do GitHub), não relativo. CONTRIBUTING.md fica na raiz do repositório, fora de docs/ — um link relativo [CONTRIBUTING.md](CONTRIBUTING.md) fazia uv run mkdocs build --strict falhar (WARNING - ... target is not found among documentation files), porque o README.md inteiro vira a home do site (task 71) mas CONTRIBUTING.md não tem stub em docs/. Mesmo ajuste que a task 66 já fez para o link de release.yml; LICENSE não precisou do mesmo tratamento porque, sem extensão .md, o mkdocs só registra um INFO (link deixado como está), não um WARNING que quebra o --strict.

CONTRIBUTING.md não duplica o "o que é SDD" do README.md — só linka para lá. O conteúdo novo é só o que ainda não existia: convenções de git (branch/commit/tag) e o checklist de pré-PR.

Suíte completa (528 testes, +3 desta task), ruff check, ruff format --check sem violações, uv run mkdocs build --strict limpo (0 avisos).

  • [x] 78. CHANGELOG.md Formato Keep a Changelog, com seção "Não lançado" para a v3 em andamento e nota apontando para specs/vN/README.md/Releases do GitHub para o histórico anterior. (R35.4)

O link do README.md para CHANGELOG.md também precisou ser absoluto, pelo mesmo motivo do CONTRIBUTING.md na task 77 — uv run mkdocs build --strict confirmado limpo depois do ajuste, antes de seguir.

A seção "Não lançado" resume a v3 em cinco bullets, sem duplicar specs/v3/README.md — que é a fonte de verdade detalhada, linkada logo no início da seção. O CHANGELOG.md existe para quem quer o resumo rápido "o que mudou", não para reescrever o design.

Suíte completa (530 testes, +2 desta task), ruff check, ruff format --check sem violações, uv run mkdocs build --strict limpo.

  • [x] 79. Dependabot, permissions de menor privilégio e template de PR .github/dependabot.yml (ecossistemas uv e github-actions); permissions: contents: read em ci.yml; .github/PULL_REQUEST_TEMPLATE.md lembrando o fluxo SDD. (R35.8, R35.9, R35.10)

docs.yml e release.yml já declaravam permissions — só ci.yml estava sem. O teste (test_every_workflow_declares_least_privilege_permissions) confere os três, não só o que mudou, para a lacuna não voltar sem ser notada se um workflow novo entrar sem o bloco.

O teste do Dependabot confere o conjunto exato de ecossistemas ({"uv", "github-actions"}), não só que "uv" está presente — um "pip" colado por engano ao lado (achado comum ao copiar exemplos da documentação do GitHub) passaria numa checagem de subconjunto e ficaria lendo o ecossistema errado silenciosamente.

Fecha o Bloco K. Todas as dez linhas de R35 na matriz de rastreabilidade têm teste agora — nenhuma ficou em branco por falta de tempo, diferente de R31/R33, onde o gate é mesmo o CI (não um test_*).

Suíte completa (533 testes, +3 desta task), ruff check/ruff format --check/ty check sem violações, uv run pre-commit run --all-files limpo (10 hooks), uv run mkdocs build --strict limpo.

Bloco L — Mudança de escopo pós-lançamento

  • [x] 80. Inverter prioridade céu/chão na sobra vertical e mover os mobs para o chão Reportada queda de FPS só no Android, atribuída à qualidade adaptativa (task 64) caindo de nível com frequência. src/viewport.py: MAX_GROUND_EXTRA vira MAX_SKY_EXTRA (mesmo valor, 2 blocos) e compute() inverte as duas linhas — agora é o céu que tem teto pequeno e o chão que absorve o resto da sobra vertical sem limite. src/mobs.py: draw_sky vira draw_ground, usando viewport.ground_band em vez de sky_band. src/game.py: a chamada de mobs em Game.draw migra para depois de Ground.draw (o mob fica sobre a textura do chão, não antes dela). src/ui.py não muda — mute_icon_rect/hud_score_center já lidavam com faixa de céu pequena ou ausente, que é exatamente o caminho hoje exercitado pela janela desktop padrão (2:3 exato). (R25.1)

Hipótese confirmada por medição, não só por inspeção. O suspeito era o próprio céu: numa tela 20:9 (BLOCKY_CANVAS=1080x2400), a faixa de céu chegava a 251px — gradiente do tamanho do canvas, duas camadas de parallax e um campo inteiro de mobs, tudo decoração que a task 64 desliga primeiro quando o FPS cai. Comparado uv run python scripts/benchmark.py --frames 300 com SDL_VIDEODRIVER=dummy antes e depois da mudança (código anterior recuperado via git stash dos três arquivos, mesma sessão): p95 caiu em todos os cenários — JOGANDO cave, o pior caso, foi de 35,52ms para 14,24ms; GAME_OVER de 37,63ms para 15,30ms; PRONTO de 17,55ms para 10,54ms. Draw calls/frame e KB transitórios/frame ficaram estatisticamente iguais (a mudança não altera o que é desenhado, só onde) — a queda é inteiramente de custo por desenho (gradiente/parallax/mobs numa faixa menor), não de menos coisas na tela.

Não verificável neste ambiente: o FPS real sustentado no Android (R27.1) e se a queda de qualidade (task 64) de fato passa a ser acionada com menos frequência num aparelho real — isso depende de instalar um .apk novo, e o .apk da task 72 é anterior a esta mudança. Fica pendente de build e instalação manual, mesmo padrão da task 72.

Testes atualizados, sem teste novo dedicado: tests/test_viewport.py (tetos invertidos), tests/test_mobs.py (rename para draw_ground, banda ground_band, incluindo o cenário de faixa curta demais para um sprite — antes "céu curto", agora "chão curto"), tests/test_bands.py/tests/test_resize.py (números do exemplo da seção 32.3 recalculados), tests/test_quality.py (comentário atualizado, lógica inalterada), tests/test_decor.py (uma âncora que comparava contra ground_y() passou a comparar contra screen_h(), porque ground_y() deixou de crescer sem limite em telas alongadas — é consequência esperada da mudança, não uma regressão).

Suíte completa (538 testes), ruff check/ruff format --check sem violações, uv run mkdocs build --strict limpo.

  • [x] 81. Última pontuação na tela PRONTO Game.last_score: int | None = None, capturado em _flap_action (ramo GAME_OVER) antes de reset() zerar self.score — nunca gravado em score.py/storage.py. ui.draw_ready_screen ganha o parâmetro last_score; quando não é None, desenha "PONTUAÇÃO ANTERIOR: N" acima do recorde, base_size=6 (entre os 5 dos créditos e os 8 do recorde) e cinza claro (210, 210, 210), para ficar visualmente subordinada ao recorde dourado. (R36.1, R36.2, R36.3)

Totalmente verificável neste ambiente — ao contrário da task 80, não depende de Android real: tests/test_game.py cobre o valor inicial None, a captura no ciclo GAME_OVER→PRONTO e que uma nova instância de Game (o análogo mais próximo de reiniciar o app, sem um segundo processo) não herda a pontuação da anterior; tests/test_ui_layout.py cobre a linha extra aparecendo/desaparecendo conforme last_score e a ausência de sobreposição com as demais linhas da tela.

Suíte completa (538 testes, incluídos os desta task), ruff check/ruff format --check sem violações, uv run mkdocs build --strict limpo.

  • [x] 82. Tolerância de sobreposição mínima nos cantos Jogador relatou colisão sensível demais "nas quinas" — a sensação de perder sem encostar. Causa: Bird.rect é sempre um quadrado reto, mas o sprite desenhado gira de +30° a −60°; a caixa reta sobra além do contorno visível da abelha justamente nas diagonais, onde ficam os cantos internos do vão das colunas e a quina do chão. src/config.py ganha CORNER_TOLERANCE = 4; src/game.py ganha _collides(a, b), função módulo-nível que substitui os bird.rect.colliderect(...) de _collision_texture — exige sobreposição ≥ CORNER_TOLERANCE px nos dois eixos, calculada por aritmética pura sobre .left/.right/.top/.bottom (sem construir pygame.Rect novo, para não quebrar o orçamento de zero alocação de tests/test_alloc.py::test_the_collision_check_builds_no_rectangle_at_all). (R3.6)

Alternativa descartada, registrada em design.md seção 6: rotacionar a hitbox junto com angle (OBB) resolveria a causa raiz de forma mais exata, mas trocaria uma comparação barata e testável por geometria de polígono rotacionado, sem ganho perceptível sobre a tolerância fixa para o sintoma relatado (resvalar em canto).

Um teste novo revelou uma armadilha na própria bateria de testes, não no código de produção. A primeira versão de test_a_shallow_corner_graze_does_not_end_the_round fixava só pipe.top_rect numa geometria conhecida e deixava pipe.bottom_rect com a abertura sorteada de verdade (gap_y aleatório) — intermitentemente, o sorteio colocava bottom_rect bem onde o teste posicionava a abelha, e o teste falhava por uma colisão real contra a metade errada da coluna, não por uma regressão no comportamento novo. Corrigido fixando os dois retângulos (top_rect e bottom_rect) antes de posicionar a abelha; confirmado sem flakiness rodando a bateria 5 vezes seguidas depois do ajuste.

Totalmente verificável neste ambiente — ajuste de regra de jogo, sem dependência de Android. tests/test_game.py: _collides testada diretamente no limite exato (CORNER_TOLERANCE - 1 não colide, CORNER_TOLERANCE colide, sem sobreposição em eixo nenhum não colide), mais dois testes de integração (resvalar raso não termina a partida; sobreposição funda nos dois eixos continua terminando — guarda de regressão para a colisão "de verdade"). A sensação de melhora em si (o "feeling" de jogar) é subjetiva e não tem asserção automatizada — validada rodando uv run main.py manualmente e resvalando cantos de coluna em ângulo.

Suíte completa (546 testes, +8 desta task), ruff check/ruff format --check sem violações, uv run mkdocs build --strict limpo.

  • [x] 83. Legibilidade da linha de última pontuação na tela PRONTO Dono do projeto reportou (via chat) que a linha de última pontuação (task 81) ficava com o texto longo demais, colada no "RECORDE: N" logo abaixo e com contraste ruim. src/ui.py: texto encurtado de "PONTUACAO ANTERIOR: N" para "ANTERIOR: N"; LAST_SCORE_COLOR trocado de cinza (210, 210, 210) para branco quase puro (235, 235, 235); gap fixo entre as duas linhas aumentado de 6 para 14 px, mais próximo dos 16 px usados nos demais espaçamentos da mesma tela. specs/v3/design.md seção 12 (R36.1) atualizado para refletir o novo texto/cor/espaçamento. (R36.1)

Totalmente verificável neste ambiente — ajuste só de apresentação, sem mudança de lógica. tests/test_ui_layout.py::test_ready_screen_with_last_score_texts_do_not_overlap e test_ready_screen_omits_last_score_line_when_none continuam cobrindo geometria (sem sobreposição, dentro dos limites da tela) sem depender do texto/cor exatos; nenhum teste novo necessário. Validado com uv run pytest (suíte completa) e inspeção visual via uv run main.py.

Checklist de verificação da v3

Verificável automaticamente / no desktop:

  • [x] Nome "Blocky Bee" em título, tela inicial, .spec do PyInstaller, buildozer.spec e artefatos de release (R22.1) — config.TITLE, BlockyBee.spec::name, tests/test_packaging.py::test_the_app_is_named_blocky_bee, nomes dos artefatos em release.yml — inspeção (sem teste automatizado dedicado ao set_caption)
  • [x] package.name = blockybee e INI bem formado (R22.2) — validação via configparser (tests/test_packaging.py)
  • [x] specs/v1/ e specs/v2/ intactas, com o nome antigo preservado (R22.4) — inspeção (nenhum arquivo de specs/v1/ ou specs/v2/ tocado nesta versão)
  • [x] Canvas lógico com a proporção da tela, área jogável sempre 480×720 (R23.4, R24.1) — tests/test_viewport.py
  • [x] Bandas fecham exatamente com o canvas, sem pixel perdido (R23.4) — tests/test_viewport.py
  • [x] Nenhuma constante de física ou de bioma alterada (R24.2) — inspeção + testes de física da v1/v2 ainda verdes, mais tests/test_bands.py::test_fall_is_identical_in_both_canvases
  • [x] Coluna nasce em play.right e não aparece fora da área jogável (R24.3, R24.4) — tests/test_bands.py
  • [x] Teto do voo na borda da área jogável, não do canvas (R24.5) — tests/test_bands.py
  • [x] Redimensionar a janela recalcula canvas e faixas, mantendo a área jogável (R23.6) — tests/test_resize.py
  • [x] Mobs sempre fora da área jogável, sem efeito em colisão ou pontuação (R25.4, R34.5) — tests/test_mobs.py
  • [x] Toque/clique em faixa decorativa dispara a ação de voar, com o ícone de mudo como única exceção (R25.6, R34.1, R34.4) — tests/test_input.py
  • [x] Mouse e toque no mesmo ponto produzem a mesma ação; coordenada fora do canvas é ignorada (R34.2, R34.3) — tests/test_input.py
  • [x] Cascata de render cai de nível sem levantar exceção (R26.2, R26.3) — tests/test_render.py
  • [x] Zero transform.rotate, random.Random ou Surface nova durante o desenho (R27.2, R27.3) — tests/test_render.py, tests/test_alloc.py, tests/test_decor.py
  • [x] Sem escrita em disco durante JOGANDO (R27.5) — tests/test_game.py
  • [x] FINGERMOTION/MOUSEMOTION bloqueados (R27.6) — tests/test_input.py
  • [x] Timestep fixo: 1 passo a 60 FPS, 2 a 30, teto respeitado em stall (R28.1–R28.3) — tests/test_game.py
  • [x] Estado após N frames idêntico ao da v2 a 60 FPS (R28.4) — tests/test_game.py
  • [x] Regras idênticas nos três níveis de qualidade (R29.3) — tests/test_quality.py
  • [x] Histerese impede oscilação de nível (R29.4) — tests/test_quality.py
  • [x] ruff check sem violações de docstring (R31.1–R31.3) — CI
  • [x] mkdocs build --strict sem avisos (R31.4) — CI
  • [x] Todo critério de aceitação presente na matriz (R32.2, R32.3) — tests/test_traceability.py
  • [x] Cobertura acima do mínimo declarado (R32.4) — CI, pyproject.toml::tool.pytest.ini_options (96% medidos, piso em 96%)
  • [x] LICENSE MIT presente e referenciada no README.md e no pyproject.toml (R35.1, R35.2) — tests/test_repo_hygiene.py
  • [x] Convenções de branch/commit/tag documentadas em CONTRIBUTING.md (R35.3) — tests/test_repo_hygiene.py
  • [x] CHANGELOG.md no formato Keep a Changelog, com seção "Não lançado" (R35.4) — tests/test_repo_hygiene.py
  • [x] Hooks de pre-commit espelham o gate de CI e rodam também no ci.yml (R35.5, R35.6) — tests/test_repo_hygiene.py, uv run pre-commit run --all-files
  • [x] .editorconfig/.gitattributes com final de linha LF e binários marcados (R35.7) — tests/test_repo_hygiene.py
  • [x] Dependabot cobrindo uv e github-actions (R35.8) — tests/test_repo_hygiene.py
  • [x] Todo workflow com permissions de menor privilégio (R35.9) — tests/test_repo_hygiene.py
  • [x] Template de Pull Request presente (R35.10) — tests/test_repo_hygiene.py
  • [x] Ganho medido contra o baseline (R30.5) — scripts/benchmark.py, registrado em design.md seção 30 (task 73)
  • [x] Sobra vertical prioriza o chão, céu com teto pequeno (R25.1) — tests/test_viewport.py, custo de desenho reduzido medido por scripts/benchmark.py (task 80)
  • [x] Última pontuação exibida acima do recorde, não persistida, ausente na primeira tela PRONTO da execução (R36.1, R36.2, R36.3) — tests/test_game.py, tests/test_ui_layout.py
  • [x] Colisão perdoa resvalar raso de canto, mas continua matando em batida de frente (R3.6) — tests/test_game.py

Requer aparelho Android real:

  • [x] Nenhuma barra preta em nenhuma borda (R23.1)
  • [x] Display em tela cheia na resolução nativa (R23.3)
  • [x] O app não gira ao virar o aparelho (R23.5)
  • [x] Faixas decorativas e mobs visíveis e coerentes com o bioma (R25.1, R25.2, R25.3)
  • [x] Toque em qualquer ponto da tela, faixas decorativas inclusive, faz a abelha voar (R34.1)
  • [x] Backend acelerado em uso, mostrado na sobreposição (R26.1, R26.5)
  • [x] 60 FPS sustentado em JOGANDO no aparelho de referência (R27.1)
  • [x] Dificuldade percebida igual à do desktop (R24)