Os Inimigos se Desfazem Pixel a Pixel
Inimigo morrendo tocava um pufe genérico de partícula amarela. Era coisa de placeholder do primeiro protótipo e sobreviveu bem mais do que merecia, porque uma coisa que acontece duzentas vezes por minuto é exatamente a coisa que vale a pena deixar boa. Esse post é sobre as quatro tentativas até chegar lá, o problema separado do splitter e o ajuste de qualidade gráfica que nasceu de uma reclamação de frame rate.
Quatro animações de morte num dia só
Tentativa um: cópias espalhadas. Algumas cópias do próprio sprite do inimigo, encolhendo e sumindo pra fora em 150ms. Tocadas inteiramente a partir do diff de inimigos mortos que o deriveFx já calcula, guardadas num array simples sem nenhum objeto de engine por morte, então continuou barato quando quarenta inimigos morrem no mesmo frame. Sim e co-op intocados, pura apresentação.
Tentativa dois: um estilhaço de verdade. Cópia de sprite inteiro lê como duplicação, não como quebra. Então em vez de sprites inteiros, quatro recortes em quadrante do sprite que está morrendo, se separando, assentando e sumindo em 250ms. Agora o inimigo continua reconhecível enquanto se despedaça.
Tentativa três: maior e mais devagar. 250ms ainda era um piscar. Duração pra um segundo inteiro, e os quadrantes viraram um grid 3x3: nove pedaços, cada um voando do centro na própria direção e velocidade aleatórias, girando pra um lado aleatório e crescendo ou encolhendo enquanto some.
Tentativa quatro: joga tudo fora. Nove recortes de tile com física na mão é muito código pra uma coisa que o LittleJS já faz. A versão final é um ParticleEmitter alimentado com o tileInfo do próprio inimigo e uma cor inicial branca (1,1,1,1), o que faz cada partícula herdar as cores reais dos pixels do sprite. Os tamanhos vão de 0.05 a 0.01 ao longo de um segundo de vida, então o inimigo lê como se dissolvesse nos próprios pixels. O pool caseiro, o updateDeathAnims e o drawDeathAnims sumiram de vez, e quem cuida do ciclo de vida do emitter é o LittleJS.
Quatro commits, saldo negativo de linhas, resultado melhor. Costuma ser assim quando você continua em vez de parar no "aceitável".
O splitter precisava do contrário
Os splitters ficaram com a animação própria, porque a morte deles não é um sumiço, é uma transformação, e estava acontecendo num frame só: pai some, dois minis já em pé ali.
Agora os minis atrasam um segundo, enfileirados pelo spawnQueue em vez de nascerem na hora da morte. Durante essa janela roda uma animação puramente visual: dois sprites de splitter começam sobrepostos na posição da morte, encolhem até o tamanho mini e se afastam ao longo do caminho até os pontos exatos onde os minis de verdade vão aparecer. O feedback do testador matou o fade no final, então os sprites ficam totalmente opacos e somem exatamente quando as entidades chegam.
Esse "exatamente" deu mais um conserto. O timer da animação roda em tempo de relógio enquanto o spawn usava tempo de simulação (g.time + 1.0), e esses dois relógios podem se desencontrar em até um frame de render, deixando um único frame em que a animação já acabou e as entidades ainda não existem. Um piscar de um frame, visibilíssimo. Os minis agora nascem em g.time + 0.95, dezenove ticks de sim em vez de vinte, então já estão no ECS no último frame da animação.
Splitters com variante também pararam de lavar as imunidades: quando um splitter imune a fogo morre, os filhos agora herdam as imunidades de tipo de dano do pai pela fila de spawn (maskToImmunities no lib/damageType, todo testado), e a animação de divisão tinge os sprites filhos com a cor misturada da variante pra você ver o que vai encarar.
Três níveis de qualidade gráfica
Um relato recorrente de playtest dizia que o jogo cai abaixo de 60fps nas waves pesadas. Três coisas estavam erradas, e nenhuma delas escalava com nada visível:
- O passo de pós-processamento (bloom/grade), o render de iluminação dinâmica e o antialiasing do WebGL ficavam sempre ligados, sem saída.
- As luzes de fogachos, explosões e escudos eram ilimitadas, então a contagem de luzes simultâneas escalava com número de torres vezes cadência. Agora tem teto de 12.
- O
drawEntities.tsrodava as queries[Tower, Position]e[Enemy, Position]até cinco vezes por frame cada. Agora uma.
A primeira saiu como um checkbox LOW GRAPHICS e na sequência virou um ajuste de GRAPHICS QUALITY em três níveis: HIGH, MEDIUM, LOW, cada um empacotando antialiasing, o passo de pós-processamento, iluminação dinâmica e filtragem de canvas/textura. Clicar cicla o nível e aplica na hora o que dá pra aplicar na hora (iluminação dinâmica, filtro de upscaling do canvas); antialiasing, pós-processamento e filtragem de textura são fixados pela engine no startup e só valem no próximo reload. A escolha fica salva no arquivo de save.
Desligar as luzes deixou tudo mais claro
Aí o LOW começou a ficar pior de um jeito interessante: estourado, superexposto, tudo lavado.
O passo de composição do LightSystemPlugin é justamente o que escurece a cena inteira até a cor de ambient da fase. Todo sprite é desenhado com brilho total na suposição de que esse passo vai rodar e escurecer. Desligar o plugin no LOW tirava a única coisa que escurecia a cena, então o jogo renderizava no brilho bruto. A iluminação dinâmica agora fica ligada em todos os níveis; o teto de luzes de flash já limita o custo real por frame, então não sobrava nada a ganhar em desligar. Os níveis agora só controlam antialiasing, pós-processamento e filtragem de textura. É a segunda vez que as suposições de ordem do plugin de iluminação me mordem.
E um crash que merece registro: o drawOptionsOverlay lia graphicsQuality direto do objeto cru de settings persistidas. Em qualquer save escrito antes desse campo existir ele é undefined, então o .toUpperCase() estourava e derrubava o loop de render inteiro no instante em que você abria o OPTIONS. O preenchimento de settings é raso, mesma armadilha do showFps e do cursor antes dele. Ler pelo getGraphicsQuality(), que cai pra 'high', resolve. Toda setting nova persistida é um jeito novo de quebrar um save antigo.
Na próxima: o boss que colhe as suas mortes.