Inicio

2024_11_02

Lo que me ha enseñado programar un videojuego de ritmo:

Este diciembre (2023) empecé a programar un videojuego como algo personal. Mi meta es programar un juego parecido a Rhythm Heaven, Bits & Bops o Melatonin. A modo de resumen, son juegos donde uno manipula únicamente una o dos acciones (un martillazo, un aplauso, un pisotón, etc. dependiendo del minijuego). Esta acción debe coincidir con el tempo de la canción. Los tres juegos dan una pista auditiva y visual de que -ya toca- realizar esa acción en ese pulso. Al presionar y soltar el botón indicado en el momento correcto, uno suele ganar puntos y desbloquear los siguientes niveles.

Existen tres juegos publicados de Rhythm Heaven, una para GameBoy, una para DS y una para 3DS (las tres, consolas portátiles de Nintendo). Bits & Bops y Melatonin en cambio son juegos hechos completamente para computadora y con miras a consola. Estos últimos están hechos con Unity. Este proyecto me ha mostrado que conforme uno entra más y más en aguas profundas uno comienza a debatir consigo mismo si se desea conseguir que las cosas funcionen para conseguir el producto final o solo para probarse a uno mismo lo mucho o lo poco que uno es capaz de conseguir.

Ésta historia es una en la que conseguí poco mas que cicatrices.

Me atrevo a decir que los juegos de ritmo están hechos de una de dos formas aunque probablemente hay más.

La primera es quizá la más intuitiva, se reproduce un a pista musical, el tiempo en el que inicia se denomina t0. Las notas que el jugador debe introducir están descritas únicamente como un número de milisegundos a partir de ese t0. (i.e. en el milisegundo 1234 desde el inicio de la canción, se espera una nota). Si la nota se presiona dentro de una tolerancia, (digamos, 50 milisegundos antes o después) la nota se toma como buena. (i.e. si t0 + 1234 +50 > t > t0 + 1234 - 50 la nota se toma por buena). Se levanta alguna bandera para evitar que se presione el tiempo una segunda o tercera vez dentro de esa ventana de 100 milisegundos evitando que se consigan puntos dos veces para la misma nota.

La segunda forma de codificar cuándo es una nota dentro de la canción es análoga a un metrónomo. Uno comienza la reproducción de una pista de audio cuyos pulsos por minuto conoce. (Es indispensable que la pista se haya grabado o sintetizado con un metrónomo). Por ejemplo, Gangnam Style de PSY "suena" a 132 pulsos por minuto. Esto significa que, una vez inicia la primera nota de la canción, cuando pase un minuto habrán sonado 131 notas más. Esto nos es útil a medida que podemos calcular el número de milisegundos entre cada nota. 132 ppm (pulsos por minuto) equivale a 0.4545... segundos entre cada nota, o 454 milisegundos entre cada nota. Así, podemos programar un "metrónomo" que inicie cuando suena la primera nota de la canción y lleve contabilidad de estos 454 milisegundos. El metrónomo deberá -saber- en qué número de nota se esperará que el jugador presione algún botón.

Cada milisegundo se notifica al metrónomo del tiempo que se ha reproducido de la música. Esto sirve para avisar al metrónomo qué nota sigue. Cuando una nota se introduzca dentro de la tolerancia considerará correcta. (i.e. 454 + 50 > t > 454 - 50). La música seguirá avanzando y se revisará ahora en el milisegundo de la segunda nota (454 * 2 = 908). Se revisará entonces en el milisegundo correspondiente (908 + 50 > t > 908 - 50).

Para desarrollar un proyecto así, parece conveniente usar un lenguaje de programación rápido. A pesar de que en los ejemplos se manejaron tiempos en milisegundos, lo más común es usar tiempos en microsegundos para evitar que los valores detrás del punto decimal se acumulen demasiado rápido; en cualquier otro tipo de juego no importaría demasiado; en uno de ritmo es vital no tener esos desfases.

Java me pareció la mejor alternativa en el momento; con recolector de basura, multiplataforma y gratuito. Me pareció mejor que Unity que pasaba por tantas polémicas.

Abrí IntelliJ Community y comenzó la aventura.

Primero, a conseguir una ventana que se pintara con suficiente velocidad (144hz a 3440x1440 me daría por servido, al menos en mi RTX3060). De aquí conocí a BufferStrategy y AffineTransform, Canvas y JFrame ya eran viejos conocidos.

Armé un árbol de herencia de objetos genéricos para cada escena; cajas de texto, botones, entidades móviles del juego etc. Cada objeto se pinta en los gráficos del Lienzo, primero una imagen de fondo, luego los objetos del escenario y por últimos los objetos que el jugador puede manipular. Por encima, una capa de depuración que muestra la información relevante para el desarrollador: cuadros y estados por segundo, el identificador de todos los objetos en pantalla, la nota musical activa y demás.

El siguiente paso era reproducir sonidos en Java. Y así comenzó la primera implementación; Clips. El manejo correcto del audio es algo complicado, o al menos así había involucrado asuntos extraños con hilos de ejecución en Arduinos y ESP32; tocaba ver cómo sería en la JVM.

La solución que todo tutorial indica es usar un "Clip". Un objeto mágico que involucra un AudioInputStream y un API fácil de usar clip.start() clip.stop() clip.setMicrosecondPosition(). Parecía muy fácil, quizá, demasiado fácil.

El Clip cuenta con un API muy cómoda, es fácil de programar y usar. El único inconveniente aparente en un principio es que únicamente puede reproducir archivos .wav . Resulta un poco incómodo en un mundo donde casi toda la música se obtiene en formato .mp3 pero no cambiaría de lenguaje de programación solo por esto.

Así fue como programé la primera escena, un Clip para reproducir la música del nivel, un clip para reproducir un "tic-tac" cada vez que el metrónomo pasara por el tiempo exacto de la nota y una cuerda de guitarra para que sonara siempre que presionara la tecla "A".

Arranca la escena y... sí, estaba la música, estaba la cuerda, estaba el tic tic tic del metrónomo pero... algo se sentía raro. Algo estaba desfasado. Cerrar el programa, re-correr. Sí, así tenía que sonar. Re-correr. Re-correr. Re-correr. Algo estaba mal, a veces la música sonaba en sincronía con el metrónomo, a veces sonaba antes, a veces sonaba después.

Si aumentaba el ritmo del metrónomo había momentos en los que se brincaba "tics" a pesar de que la consola indicaba que se había solicitado correctamente el pulso. ¿Por qué era tan irregular el desfase? Optimización tras optimización, todo me fue indicando que mi código estaba bien, ¿y el Clip?