Arquitetura do Player

Por que o Player do Romergo funciona com o DOM comum

Em geral, um motor de visual novel não é tão complexo quanto o de um jogo de tiro 3D ou de estratégia em tempo real. Ele não precisa calcular a cada segundo a física de um mundo enorme, o comportamento de centenas de objetos e uma iluminação complexa. Mas isso não significa que basta trocar imagens ao clicar.

Uma cena comum de visual novel é formada por um conjunto bastante compreensível:

Toda a magia surge da mudança desses parâmetros ao longo do tempo. Não importa apenas o que mostrar, mas quando mostrar, o que deixar inalterado, como continuar a música entre cenas, qual idioma escolher e para onde avançar depois da ação do jogador.

Procurei um motor por muito tempo e escolhi o navegador

Tentei montar a cena com Godot, PixiJS e Three.js. Com resultados variados, tudo funcionava, e os motores em si não tinham culpa. Mas, para a minha forma de criar quests, eles acrescentavam uma camada com a qual eu precisava negociar separadamente.

Eu não precisava apenas de uma tela de jogo pronta. Era importante ver imediatamente qualquer alteração no Builder, executar uma única cena sem compilar o jogo inteiro, selecionar e mover elementos diretamente na prévia e depois obter o mesmo comportamento no Player publicado.

No fim, a solução mais eficaz foi a mais simples: o DOM comum do navegador.

O plano de fundo, os personagens e o diálogo continuam sendo camadas comuns do navegador. A maior parte da composição e dos efeitos é construída com CSS. Canvas 2D é usado para elementos visuais específicos, enquanto um canvas WebGL é conectado onde transições com shaders entre imagens são realmente necessárias.

O som funciona por meio do HTMLAudioElement do navegador. O runtime cria o áudio dos clipes ativos, controla volume e repetição e mantém a mesma música durante a transição quando o asset não mudou.

Essa não é uma receita universal para qualquer jogo. Mas, para uma visual novel, o DOM não foi um compromisso, e sim uma ferramenta muito precisa.

Uma cena simples e um runtime inteligente

Mentalmente, divido o Player em duas partes:

A cena não precisa saber por que um personagem específico apareceu, qual condição liberou uma resposta ou qual capítulo vem depois. Ela recebe clipes, o tempo atual, o idioma, o modo de tela e referências de mídia. Em seguida, renderiza o resultado e devolve as ações do jogador.

O runtime recebe a descrição da quest e o estado da partida. Ele conhece o capítulo, o nó e a cena atuais, os valores das variáveis, as escolhas feitas, os pontos de controle e os capítulos concluídos. Quando o jogador clica na cena ou escolhe uma resposta, o runtime aplica os efeitos, encontra a próxima etapa e volta a entregar à cena um estado pronto.

Simplificando bastante, o ciclo é assim:

JSON da quest + progresso salvo
          ↓
o runtime determina a etapa atual
          ↓
a cena mostra clipes e mídias
          ↓
a ação do jogador volta ao runtime
          ↓
próxima etapa — o ciclo se repete até o final

Em um jogo publicado, o Player carrega um snapshot fixo do runtime de toda a quest. No Builder, a prévia cria um runtime compatível a partir do rascunho atual. Portanto, não são dois players parecidos que começam a se comportar de maneiras diferentes com o tempo, mas um mesmo RuntimePlayer, SceneStage e viewport dentro de envoltórios diferentes.

Como a continuidade é mantida entre as cenas

Durante uma transição, o runtime recalcula o estado da nova cena. Uma música repetida é reconhecida pelo ID do asset e continua sem reiniciar nem repetir o fade-in. O navegador carrega e armazena as imagens em cache, então reutilizar um plano de fundo não significa baixar o arquivo da rede outra vez.

Assim, a otimização não está em uma grande condição de “não enviar nada”, mas nas camadas corretas: o runtime mantém a continuidade da reprodução, o resolver devolve recursos estáveis e o cache do navegador não baixa novamente as mídias já conhecidas.

Duas telas para uma cena

A tarefa mais desagradável para mim não foi a renderização em si, mas a adaptação para o celular. Simplesmente reduzir uma cena horizontal deixa os personagens pequenos demais, aperta o diálogo e pode empurrar detalhes importantes do plano de fundo para fora da tela.

No Romergo, uma cena tem dois viewports virtuais fixos: horizontal 1280 × 720 e vertical 390 × 693. O Player escolhe o modo adequado conforme o dispositivo e a orientação e depois redimensiona a cena pronta como um todo.

O layout desktop continua sendo o principal. Para o celular, é possível definir posições e escala separadas dos personagens; se não houver uma substituição, a versão desktop é usada com redução adicional. Assim, o autor não edita duas cenas independentes, mas uma única cena com ajustes móveis pontuais.

Para o plano de fundo, bgX, bgY e a escala são compartilhados entre os dois modos, enquanto o viewport vertical recorta a mesma imagem do seu próprio jeito. Antes de publicar, portanto, é preciso verificar o enquadramento nos dois formatos e escolher um foco comum. Configurações separadas de fundo para celular não fazem parte do contrato do runtime.

Ajustes repetitivos de personagens podem ser delegados a um agente de IA por MCP e depois verificados na prévia real dos dois viewports. É por isso que chamo a adaptação de semiautomática, e não totalmente automática.

Como não mostrar uma tela preta ao jogador

Uma cena simples não ajuda se a imagem necessária ainda não chegou pela rede. Por isso, o carregamento também se tornou parte do contrato do runtime.

Antes da primeira renderização, o Player reúne as mídias ativas da cena inicial e espera que sejam carregadas. Durante esse tempo, o jogador vê um indicador de progresso adequado, não um fundo vazio. Depois de iniciar, o runtime espera um pouco e aquece os recursos da cena atual e das cenas alcançáveis nos próximos dois passos do grafo. Por padrão, a profundidade é de duas transições, incluindo todas as ramificações possíveis nos dois passos.

O pré-carregamento segue o grafo de transições, e sua profundidade pode ser configurada separadamente. Não se usa aqui um número fixo de cenas: uma sequência linear e uma ramificação geram cargas diferentes, mesmo quando formalmente há o mesmo número de etapas à frente.

Para jogar offline, existe outro modo: o usuário pode baixar toda a quest antecipadamente. O snapshot do runtime, as mídias e o envoltório PWA ficam no cache do navegador e abrem sem rede. O pré-carregamento da próxima cena oferece uma experiência online fluida; o download completo oferece um verdadeiro modo offline.

Telegram como envoltório, Discord como sistema separado

Meu amor pelos navegadores valeu especialmente a pena ao incorporar o Player ao Telegram e ao Discord. Nos dois casos, o mesmo web runtime é aberto dentro da plataforma, então a cena e as regras da partida não precisaram ser reescritas para outro motor de jogo.

Para o Telegram, foram necessários principalmente uma sessão da plataforma, o início da quest, o progresso local e a navegação entre o Mini App e o Player comum. O jogo em si continuou o mesmo.

O Discord é mais complexo porque várias pessoas precisam ver o mesmo ponto da história. Cada participante executa seu próprio runtime local, mas o host é a fonte da verdade. O Player do host envia o estado quando há mudanças e aproximadamente a cada 750 milissegundos. A sala realtime recebe o comando por WebSocket, salva o snapshot e o distribui aos participantes. Os runtimes dos espectadores aplicam o estado remoto, restauram a cena e continuam a contagem local do tempo entre as sincronizações.

Essa separação trouxe um resultado útil: o estado da história é compartilhado, enquanto o idioma e o tamanho da tela continuam locais. Um participante pode ver a cena em russo no celular, outro em inglês em uma tela grande, e ambos permanecem sincronizados com o host. Os espectadores também podem votar nas respostas sem se tornarem um segundo host.

Uma base para todos os modos

Há muito tempo o Player ultrapassou o conjunto básico de “plano de fundo, personagens e fala”. Agora ele inclui ramificações, variáveis, pontos de controle, retrocesso, cenas de interface interativas, traduções, modo offline e partidas sincronizadas.

Mas a decisão básica resistiu ao crescimento. A cena continua cuidando da imagem e do som. O runtime continua cuidando do estado e das transições. As APIs do navegador cobrem renderização, mídia, cache e incorporação, enquanto camadas especializadas são adicionadas apenas onde são realmente indispensáveis.

Graças a isso, o mesmo Player pode ser mantido na prévia do Builder, nos testes, no jogo comum pelo navegador, no Telegram e no Discord. Para mim, é um bom exemplo de como a solução mais simples e um pouco rudimentar pode ser a mais flexível, quando a fronteira de responsabilidades é bem definida.

Voltar ao blog técnico