Arquitectura del Player
Por qué el Player de Romergo funciona con el DOM normal
En general, un motor de novela visual no es tan complejo como el de un shooter 3D o un juego de estrategia en tiempo real. No necesita calcular cada segundo la física de un mundo enorme, el comportamiento de cientos de objetos y una iluminación compleja. Pero eso no significa que baste con cambiar imágenes al hacer clic.
Una escena habitual de una novela visual se compone de un conjunto bastante comprensible:
- uno o varios fondos;
- personajes y sus posiciones;
- la réplica, el nombre y el retrato de quien habla;
- efectos visuales y transiciones;
- música y sonidos;
- una elección u otra acción del jugador.
Toda la magia nace de cambiar estos parámetros a lo largo del tiempo. No solo importa qué mostrar, sino cuándo hacerlo, qué dejar sin cambios, cómo mantener la música entre escenas, qué idioma elegir y adónde pasar después de la acción del jugador.
Busqué un motor durante mucho tiempo y elegí el navegador
Probé a construir la escena con Godot, PixiJS y Three.js. Con resultados desiguales, todo funcionaba, y los motores no tenían la culpa. Pero para mi forma de crear quests añadían una capa con la que tenía que negociar por separado.
No necesitaba únicamente una pantalla de juego terminada. Para mí era importante ver al instante cualquier cambio en Builder, ejecutar una sola escena sin compilar todo el juego, seleccionar y mover elementos directamente en la vista previa y después obtener el mismo comportamiento en el Player publicado.
Al final, la solución más eficaz resultó ser la más sencilla: el DOM normal del navegador.
El fondo, los personajes y el diálogo siguen siendo capas habituales del navegador. La mayor parte de la composición y los efectos se construye con CSS. Canvas 2D se utiliza para elementos visuales concretos, y se conecta un canvas WebGL allí donde realmente hacen falta transiciones con shaders entre imágenes.
El sonido funciona mediante el HTMLAudioElement del navegador. El runtime crea audio para los clips activos, controla el volumen y la repetición, y conserva la misma música durante una transición si su asset no ha cambiado.
No es una receta universal para cualquier juego. Pero para una novela visual, el DOM no resultó ser un compromiso, sino una herramienta muy precisa.
Una escena sencilla y un runtime inteligente
Mentalmente divido el Player en dos partes:
- la escena «tonta» muestra y reproduce lo que recibe;
- el runtime «inteligente» guarda el estado de la historia y decide qué debe suceder después.
La escena no debe saber por qué apareció un personaje concreto, qué condición desbloqueó una respuesta ni qué capítulo vendrá después. Recibe clips, la hora actual, el idioma, el modo de pantalla y referencias a los medios. Después representa el resultado y devuelve las acciones del jugador.
El runtime recibe la descripción de la quest y el estado de la partida. Conoce el capítulo, el nodo y la escena actuales, los valores de las variables, las elecciones realizadas, los puntos de control y los capítulos completados. Cuando el jugador pulsa la escena o elige una respuesta, el runtime aplica los efectos, encuentra el siguiente paso y vuelve a entregar a la escena un estado listo.
Simplificando mucho, el ciclo es así:
JSON de la quest + partida guardada
↓
el runtime determina el paso actual
↓
la escena muestra clips y medios
↓
la acción del jugador vuelve al runtime
↓
siguiente paso: el ciclo se repite hasta el final
En un juego publicado, el Player carga una instantánea fija del runtime de toda la quest. En Builder, la vista previa crea un runtime compatible a partir del borrador actual. Por eso no son dos reproductores parecidos que con el tiempo empiezan a comportarse de manera distinta, sino un mismo RuntimePlayer, SceneStage y viewport dentro de envoltorios diferentes.
Cómo se mantiene la continuidad entre escenas
Durante una transición, el runtime vuelve a calcular el estado de la nueva escena. La música repetida se reconoce por el ID del asset y continúa sin volver a empezar ni repetir el fade-in. El navegador carga y almacena las imágenes en caché, por lo que reutilizar un fondo no implica volver a descargar el archivo de la red.
Así, la optimización no vive en una gran condición de «no enviar nada», sino en las capas adecuadas: el runtime mantiene la continuidad de reproducción, el resolver devuelve recursos estables y la caché del navegador no vuelve a descargar medios conocidos.
Dos pantallas para una misma escena
La tarea más desagradable para mí no fue el renderizado, sino la adaptación al teléfono. Reducir sin más una escena horizontal hace que los personajes queden demasiado pequeños, comprime el diálogo y puede dejar fuera del encuadre detalles importantes del fondo.
En Romergo, una escena tiene dos viewports virtuales fijos: uno horizontal de 1280 × 720 y otro vertical de 390 × 693. El Player elige el modo adecuado según el dispositivo y la orientación, y después escala la escena terminada como un todo.
El diseño desktop sigue siendo el principal. Para el teléfono se pueden definir posiciones y escala independientes de los personajes; si no hay una sobrescritura, se usa la variante desktop con una reducción adicional. Por tanto, el autor no edita dos escenas independientes, sino una sola escena con ajustes móviles concretos.
Para el fondo, bgX, bgY y la escala son comunes a ambos modos, mientras que el viewport vertical recorta la misma imagen a su manera. Antes de publicar hay que comprobar el encuadre en los dos formatos y elegir un foco común. Los ajustes independientes del fondo para móvil no forman parte del contrato del runtime.
Los ajustes repetitivos de personajes se pueden delegar a un agente de IA mediante MCP y luego comprobar en la vista previa real de ambos viewports. Por eso llamo a la adaptación semiautomática y no completamente automática.
Cómo evitar una pantalla negra para el jugador
Una escena sencilla no sirve de nada si la imagen necesaria todavía no ha llegado por la red. Por eso la carga también pasó a formar parte del contrato del runtime.
Antes del primer renderizado, el Player reúne los medios activos de la escena inicial y espera a que se carguen. Mientras tanto, el jugador ve un indicador de progreso normal, no un fondo vacío. Tras el inicio, el runtime espera un poco y precarga los recursos de la escena actual y de las escenas alcanzables en los dos pasos siguientes del grafo. De forma predeterminada, la profundidad es de dos transiciones e incluye todas las ramas posibles en ambos pasos.
La precarga sigue el grafo de transiciones y su profundidad se puede configurar por separado. Aquí no se utiliza una cantidad fija de escenas: una secuencia lineal y una bifurcación generan cargas diferentes aunque formalmente tengan el mismo número de pasos por delante.
Para jugar sin conexión existe otro modo: el usuario puede descargar toda la quest de antemano. La instantánea del runtime, los medios y el envoltorio PWA quedan entonces en la caché del navegador y se abren sin red. La precarga de la escena siguiente aporta fluidez en línea; la descarga completa permite un verdadero modo offline.
Telegram como envoltorio, Discord como sistema independiente
Mi amor por los navegadores dio un resultado especialmente bueno al integrar el Player en Telegram y Discord. En ambos casos se abre el mismo web runtime dentro de la plataforma, así que no hubo que reescribir la escena ni las reglas de la partida para otro motor de juego.
Para Telegram hicieron falta principalmente una sesión de plataforma, el inicio de la quest, el progreso local y la transición entre la Mini App y el Player normal. El juego en sí siguió siendo el mismo.
Discord es más complejo porque varias personas deben ver el mismo punto de la historia. Cada participante ejecuta su propio runtime local, pero el host es la fuente de verdad. Su Player envía el estado cuando hay cambios y aproximadamente cada 750 milisegundos. La sala realtime recibe el comando por WebSocket, guarda la instantánea y la distribuye a los participantes. Los runtimes de los espectadores aplican el estado remoto, restauran la escena y continúan midiendo el tiempo localmente entre sincronizaciones.
Esta separación produjo un resultado útil: el estado de la historia es compartido, pero el idioma y el tamaño de pantalla siguen siendo locales. Un participante puede ver la escena en ruso desde un teléfono, otro en inglés desde una pantalla grande, y ambos permanecen sincronizados con el host. Los espectadores también pueden votar por las respuestas sin convertirse en un segundo host.
Una sola base para todos los modos
Hace tiempo que el Player dejó atrás el conjunto básico de «fondo, personajes y réplica». Ahora incluye ramas, variables, puntos de control, rebobinado, escenas de interfaz interactivas, traducciones, modo offline y partidas sincronizadas.
Pero la decisión básica resistió el crecimiento. La escena sigue ocupándose de la imagen y el sonido. El runtime sigue ocupándose del estado y las transiciones. Las API del navegador cubren el renderizado, los medios, la caché y la integración, mientras que las capas especiales solo se añaden donde son realmente imprescindibles.
Gracias a ello, el mismo Player puede mantenerse en la vista previa de Builder, en las pruebas, en el juego normal del navegador, en Telegram y en Discord. Para mí es un buen ejemplo de cómo la solución más sencilla y algo rudimentaria puede resultar la más flexible, si se traza correctamente el límite de responsabilidades.