
Saludos Sucedió que durante tres años seguidos, como un regalo para el Año Nuevo a ciertas personas, he estado haciendo un juego. En 2018, fue un juego de plataformas con elementos de rompecabezas, sobre el cual escribí en un centro En 2019, un RTS de red para dos jugadores, sobre el que no escribí nada. Y finalmente, en el 2020: una historia corta visual, que se discutirá más adelante, creada en un tiempo muy limitado.
En este articulo:
- diseño e implementación del motor para cuentos visuales,
- un juego con una trama no lineal en 8 horas,
- eliminación de la lógica del juego en scripts en su propio idioma.
Interesante? Entonces bienvenido al gato.
Precaución: hay mucho texto e imágenes de ~ 3.5mb
Contenido:
0. Justificación para el desarrollo del motor.
- La elección de la plataforma.
- Arquitectura del motor y su implementación:
2.1. Declaración del problema.
2.2. Arquitectura e implementación. - Lenguaje de script:
3.1. Idioma.
3.2. Intérprete - Desarrollo de juegos:
4.1. La historia y el desarrollo de la lógica del juego.
4.2. Gráficos - Estadísticas y resultados.
Nota: Si por alguna razón no está interesado en los detalles técnicos, puede pasar directamente al paso 4 "Desarrollo del juego", sin embargo, omitirá la mayor parte del contenido.
0. Justificación para el desarrollo del motor.
Por supuesto, hay una gran cantidad de motores listos para historias cortas visuales, que, sin duda, son mejores que la solución que se describe a continuación. Sin embargo, sin importar qué tipo de programador fuera, si no hubiera escrito otro. Por lo tanto, imaginemos que su desarrollo estaba justificado.
La elección, de hecho, era pequeña: Java o C ++. Sin pensarlo dos veces, decidí implementar mi plan en Java, porque para un desarrollo rápido, ofrece todas las posibilidades (es decir, administración automática de memoria y mayor simplicidad en comparación con C ++, que oculta muchos detalles de bajo nivel y, como resultado, permite menos énfasis en el lenguaje en sí mismo y piensa solo en la lógica empresarial), y también proporciona soporte para ventanas, gráficos y audio listos para usar.
Swing fue elegido para implementar la interfaz gráfica, ya que usé Java 13, donde JavaFX ya no es parte de la biblioteca, y agregué decenas de megabytes de OpenJFX dependiendo de que fuera demasiado vago. Quizás esta no fue la mejor solución, pero no obstante.
Probablemente surja la pregunta: ¿qué tipo de motor de juego es, pero sin aceleración de hardware? La respuesta radica en la falta de tiempo para lidiar con OpenGL, así como su absoluta falta de sentido: FPS no es importante para una novela visual (en cualquier caso, con tanta animación y gráficos como en este caso).
2. La arquitectura del motor y su implementación.
2.1 enunciado del problema
Para decidir cómo hacer algo, debe decidir por qué. Este soy yo sobre la declaración del problema, porque la arquitectura no es universal, pero un motor "específico de dominio", por definición, depende directamente del juego deseado.
Por motor universal, entiendo el motor que admite conceptos de nivel relativamente bajo, como "Objeto de juego", "Escena", "Componente". Se decidió que no fuera un motor universal, ya que esto reduciría significativamente el tiempo de desarrollo.
Según lo planeado, el juego debe constar de las siguientes partes:

Es decir, hay un fondo para cada escena, el texto principal, así como un campo de texto para la entrada del usuario (la novela visual fue concebida con una entrada arbitraria del usuario, y no con una elección de las opciones propuestas, como suele ser el caso. Más adelante explicaré por qué esto era malo decisión). El diagrama también muestra que puede haber varias escenas en el juego y, como resultado, se pueden hacer transiciones entre ellas.
Nota: Por escena me refiero a la parte lógica del juego. El criterio para la escena puede ser el mismo fondo en esta misma parte.
También entre los requisitos para el motor estaba la capacidad de reproducir audio y mostrar mensajes (con la función opcional de entrada del usuario).
Quizás el deseo más importante fue el deseo de escribir la lógica del juego no en Java, sino en un lenguaje declarativo simple.
También hubo un deseo de darse cuenta de la posibilidad de animación de procedimiento, es decir, el movimiento elemental de imágenes, mientras que en el nivel de Java era posible determinar la función por la cual se considera la velocidad de movimiento actual (por ejemplo, para que el gráfico de velocidad sea directo, o sinusoide, u otra cosa).
Según lo planeado, toda la interacción del usuario debía hacerse a través de un sistema de diálogos. En este caso, el diálogo se consideró no necesariamente un diálogo con el NPC o algo similar, sino que en general fue una reacción a cualquier entrada del usuario para la cual se registró el controlador correspondiente. No está claro Se aclarará pronto.
2.2. Arquitectura e implementacion
Dado todo lo anterior, puede dividir el motor en tres partes relativamente grandes que corresponden a los mismos paquetes de Java:
display
: contiene todo lo que concierne a la salida para el usuario de cualquier información (gráfico, texto y sonido), así como la recepción de su información. Una especie de (Ver), si hablamos de MVC / MVP / etc.initializer
: contiene clases en las que el motor se inicializa y se inicia.sl
: contiene herramientas para trabajar con el lenguaje de secuencias de comandos (en adelante, SL).
En este párrafo consideraré las dos primeras partes. Comenzaré con el segundo.
La clase initializer tiene dos métodos principales: initialize()
y run()
. Inicialmente, el control llega a la clase de initialize()
, desde donde initialize()
llama initialize()
. Después de la llamada, el inicializador analiza los parámetros pasados al programa (la ruta al directorio con búsquedas y el nombre de la búsqueda a ejecutar), carga el manifiesto de la búsqueda seleccionada (al respecto un poco más tarde), inicializa la pantalla, verifica si la versión de idioma (SL) requerida por la búsqueda es compatible con los datos intérprete, y finalmente, lanza un hilo separado para la consola del desarrollador.
Inmediatamente después de eso, si todo salió bien, el lanzador llama al método run()
, que pone en marcha la carga real de la búsqueda. Primero, están todos los scripts relacionados con la búsqueda descargada (sobre la estructura del archivo de búsqueda, a continuación), se envían al analizador y el resultado se entrega al intérprete. Luego, se inicia la inicialización de todas las escenas y el inicializador completa la ejecución de su flujo, colgando finalmente el controlador de la tecla Intro en la pantalla. Y así, cuando el usuario presiona Enter, se carga la primera escena, pero más sobre eso más tarde.
La estructura de archivos de la búsqueda es la siguiente:

Hay una carpeta separada para la búsqueda, en la raíz del cual se encuentra el manifiesto, así como tres carpetas adicionales: audio
- para sonido, graphics
- para la parte visual y scenes
- para guiones que describen escenas.
Me gustaría describir brevemente el manifiesto. Contiene los siguientes campos:
sl_version_req
: versión de SL necesaria para comenzar la búsqueda,init_scene
: el nombre de la escena desde la que comienza la búsqueda,quest_name
: un hermoso nombre de búsqueda que aparece en el título de la ventana,resolution
: la resolución de la pantalla para la que está destinada la búsqueda (algunas palabras sobre esto más adelante),font_size
: tamaño de fuente para todo el texto,font_name
es el nombre de la fuente para todo el texto.
Vale la pena señalar que durante la inicialización de la pantalla, entre otras cosas, se realizó el cálculo de la resolución de representación: es decir, la resolución requerida se tomó del manifiesto y se exprimió en el espacio disponible para la ventana para que:
- la relación de aspecto se mantuvo igual que en la resolución del manifiesto,
- todo el espacio disponible estaba ocupado en ancho o en altura.
Gracias a esto, el desarrollador de la búsqueda puede estar seguro de que sus imágenes, por ejemplo 16: 9, se mostrarán en cualquier pantalla en esta proporción.
Además, cuando se inicializa la pantalla, el cursor está oculto, ya que no está involucrado en el juego.
En pocas palabras sobre la consola del desarrollador. Fue desarrollado por las siguientes razones:
- Para depurar
- Si algo sale mal durante el juego, se puede solucionar a través de la consola del desarrollador.
Implementó solo unos pocos comandos, a saber: generar descriptores de un tipo específico y su estado, generar hilos de trabajo, reiniciar la pantalla y el comando más importante: exec
, que permitía ejecutar cualquier código SL en la escena actual.
Esto finaliza la descripción del inicializador y las cosas relacionadas, y podemos proceder a la descripción de la pantalla.
Su estructura final es la siguiente:

A partir del enunciado del problema, podemos concluir que todo lo que habrá que hacer es dibujar imágenes, dibujar texto, reproducir audio.
¿Cómo se dibuja el texto / imagen en motores universales y más allá? Hay un método de update()
de tipo update()
, que se llama cada tick / step / frame / render / frame / etc y en el que hay una llamada a un método de tipo drawText()
/ drawImage()
- esto asegura la aparición de texto / imagen en este marco. Sin embargo, tan pronto como se detiene la llamada a tales métodos, se detiene la representación de las cosas correspondientes.
En mi caso, se decidió hacer un poco diferente. Dado que para las novelas visuales, el texto y las imágenes son relativamente permanentes, y también son casi todo lo que el usuario ve (es decir, son lo suficientemente importantes), se crearon como objetos de juego, es decir, cosas que solo necesitas generar y no desaparecerán hasta que les preguntes Además, esta solución simplificó la implementación.
Un objeto (desde el punto de vista de OOP) que describe el texto / imagen se llama descriptor. Es decir, para el usuario del motor API solo hay descriptores que se pueden agregar al estado de visualización y eliminar de él. Por lo tanto, en la versión final de la pantalla hay los siguientes descriptores (corresponden a las clases del mismo nombre):
La pantalla también contiene campos para el receptor de entrada actual (descriptor de entrada) y un campo que indica qué descriptor de texto ahora tiene foco y cuyo texto se desplazará bajo las acciones correspondientes por parte del usuario.
El ciclo del juego se parece a esto:
- Procesamiento de audio: llamar al método
update()
en los descriptores de audio, que verifica el estado actual del audio, libera memoria (si es necesario) y realiza otros trabajos técnicos. - Procesar pulsaciones de teclas: transfiera los caracteres ingresados a un descriptor para recibir entradas, procesando pulsaciones de teclas para las teclas de desplazamiento (flechas arriba y abajo) y Retroceso.
- Procesamiento de animación.
- Borrar el fondo en el búfer de representación (
BufferedImage
sirvió como el búfer). - Dibujando imágenes.
- Representación de texto
- Dibujar campos para entrada.
- La salida del búfer a la pantalla.
- Manejo de
PostWorkDescriptor
's. - Algunos trabajan para reemplazar los estados de la pantalla, que analizaré más adelante (en la sección sobre el intérprete de SL).
- Detenga el flujo durante un tiempo calculado dinámicamente para que el FPS sea igual al valor especificado (30 por defecto).
Nota: Quizás surja la pregunta: "¿Por qué representar campos de entrada si se han creado descriptores de texto apropiados para ellos que se representarán un paso antes?" De hecho, la representación en el párrafo 7 no ocurre, solo los InputDescriptor
del InputDescriptor
se sincronizan con los InputDescriptor
del InputDescriptor
, como la visibilidad de la pantalla, la posición, el tamaño y otros. Esto se hizo, como se indicó anteriormente, por la razón de que el usuario no controla directamente el descriptor de entrada correspondiente con un descriptor de texto y generalmente no sabe nada al respecto.
Vale la pena señalar que el tamaño y la posición de los elementos en la pantalla no se establecen en píxeles, sino en tamaños relativos: números del 0 al 1 (diagrama a continuación). Es decir, todo el ancho para renderizar es 1, y toda la altura es 1 (y no son iguales, lo que olvidé algunas veces y luego lamenté). También valdría la pena hacer que (0,0) sea el centro, y el ancho / alto debería ser igual a dos, pero por alguna razón lo olvidé / no lo pensé. Sin embargo, incluso la opción con un ancho / alto igual a 1 simplificó la vida del desarrollador de la misión.

Algunas palabras sobre el sistema para liberar memoria.
Cada descriptor tenía un setDoFree(boolean)
, al que el usuario tenía que llamar si quería destruir el descriptor dado. La recolección de basura para descriptores de algún tipo ocurrió inmediatamente después de procesar todos los descriptores de este tipo. Además, el audio que se reprodujo una vez se eliminó automáticamente una vez que finalizó la reproducción. Exactamente lo mismo que la animación sin bucle.
Por lo tanto, en este momento puede dibujar lo que quiera, pero esta no es la imagen de arriba, en la que solo hay un fondo, el texto principal y un campo de entrada. Y aquí viene el contenedor sobre la pantalla, que corresponde a la clase DefaultDisplayToolkit
.
Cuando se inicializa, simplemente agrega descriptores para el fondo, el texto, etc. a la pantalla. También sabe cómo mostrar mensajes con el icono opcional, el campo de entrada y la devolución de llamada.
Luego apareció un pequeño error, cuya corrección completa requeriría rehacer la mitad del sistema de representación: si observa el orden de representación en el bucle del juego, puede ver que las imágenes se dibujan primero y solo luego el texto. Al mismo tiempo, cuando el kit de herramientas muestra la imagen, la coloca en el centro de la pantalla en ancho y alto . Y si hay mucho texto en el mensaje, entonces debería superponerse parcialmente al texto principal de la escena. Sin embargo, dado que el fondo del mensaje es una imagen (completamente negra, pero no obstante), y las imágenes se dibujan antes del texto, un texto se superpone a otro (captura de pantalla a continuación). El problema se resolvió parcialmente centrando verticalmente no en la pantalla, sino en el área sobre el texto principal. Una solución completa incluiría introducir un parámetro de profundidad y rehacer los renderizadores de la palabra "completamente".
Demostración de superposición Quizás se trata de la pantalla, finalmente, de todo. Puede pasar al idioma, la API completa para trabajar que está contenida en el paquete sl
.
3. Lenguaje de script
Nota: Si el respetado% USERNAME% lo leyó aquí, lo hizo bien y le pediría que no dejara de hacerlo: ahora será mucho más interesante que antes.
3.1. Idioma
Inicialmente, quería hacer un lenguaje declarativo en el que solo fuera necesario indicar todos los parámetros necesarios para la escena, y eso es todo. El motor tomaría toda la lógica. Sin embargo, al final, llegué al lenguaje de procedimiento, incluso con elementos de OOP (apenas distinguibles), y esta fue una buena solución, ya que, en comparación con la versión declarativa, me dio la oportunidad de tener mucha más flexibilidad en la lógica del juego.
La sintaxis del lenguaje fue pensada para ser lo más simple posible para el análisis, lo cual es lógico, dada la cantidad de tiempo disponible.
Entonces, el código se almacena en archivos de texto con la extensión SSF; cada archivo contiene una descripción de una o más escenas; cada escena contiene cero o más acciones; cada acción contiene cero o más operadores.
Una pequeña explicación sobre los términos. Una acción es solo un procedimiento sin la posibilidad de pasar argumentos (de ninguna manera impidió el desarrollo del juego). Aparentemente, el operador no es exactamente lo que significa esta palabra en los idiomas ordinarios (+, -, /, *), pero la forma es la misma: el operador es la totalidad de su nombre y todos sus argumentos.
Quizás esté ansioso por ver finalmente el código fuente de SL, aquí está:
scene dungeon { action init { load_image "background" "dungeon/background.png" load_image "key" "dungeon/key.png" load_audio "background" "dungeon/background.wav" load_audio "got_key" "dungeon/got_key.wav" } action first_come { play "background" loop set_background "background" set_text "some text" add_dialog "(||(|) (||-))" "dial_look_around" dial_look_around on } //some comment action dial_look_around { play "got_key" once show "some text 2" "key" none tag "key" switch_dialog "dial_look_around" off } }
Ahora queda claro qué es el operador. También se ve que cada acción es un bloque de declaraciones (una declaración puede ser un bloque de declaraciones), así como el hecho de que los comentarios de una sola línea son compatibles (no tenía sentido ingresar los de varias líneas, además, no utilicé los de una sola línea).
En aras de la simplificación, un concepto como "variable" no se introdujo en el lenguaje; Como resultado, todos los valores utilizados en el código son literales. Dependiendo del tipo, se distinguen los siguientes literales:
Algunas palabras sobre el análisis del lenguaje. Hay varios niveles de "carga" del código (diagrama a continuación):
- Un tokenizer es una clase modular para dividir el código fuente en tokens (las unidades semánticas mínimas del lenguaje). Cada tipo de token está asociado con un número: su tipo. ¿Por qué modular? Porque aquellas partes del tokenizer que verifican si alguna parte del código fuente es un token de cierto tipo se aíslan del tokenizer y se descargan desde el exterior (del segundo párrafo).
- El complemento tokenizer es una clase que define la apariencia de cada tipo de token en SL; en el nivel inferior usa un tokenizer. También aquí está la selección de tokens espaciales y la omisión de comentarios de una sola línea. La salida proporciona un flujo limpio de tokens, que se utiliza en ...
- ... un analizador (también es modular), que produce un árbol de sintaxis abstracta en la salida. Modular: porque solo puede analizar escenas y acciones, pero no sabe cómo analizar operadores. Por lo tanto, los módulos se cargan en él (de hecho, él mismo los carga en el constructor, lo que no es muy bueno), que puede analizar cada uno de sus operadores.

Ahora, brevemente sobre los operadores, para que aparezca una idea de la funcionalidad del lenguaje. Inicialmente, había 11 operadores, luego, en el proceso de pensar en el juego, algunos se fusionaron en uno, algunos cambiaron y se agregaron 9 más. Aquí está la tabla resumen:
Operadores para trabajar con contadores: variables enteras específicas de la escena.
También se pensó en introducir una return
(incluso se agregó la funcionalidad correspondiente en el nivel central del intérprete), pero se me olvidó y no fue útil.
, , : show_motion
(, , 0.01) duration
.
, (lookup) ( ): ///, load_audio
/ load_image
/ counter_set
/ add_dialog
. , , , , — . . , . , : " scene_coast.dialog_1
" — dialog_1
scene_coast
.
SL-, . , , , — . : (-, ), , lookup
', , , . , goto
lookup
', .
- — - , , n
( ) . , , n
. , .
. :
add_dialog "regexp" "dialog_name" callback on/off
, . , : , , , ( ).
, , ( ) ( ) , ( ). : , , , "" "".

, ( , )
, "":
(||((|||) ( )?(||)?)|(||)( )?| )
***
, : — , , — .
, :
3.2.
: , — "" ( ). .
SL , - . :
init
— , ( , , , ).first_come
— , . , , .- , :
come
— , ( ).
: init
first_come
— , .
. : , , init
-. , ( ) .
, n
, first_come
- ( - - ). . , : , , first_come
come
, come
( ). : , , , .
(, "", " ", " " . .). , , - - . , ( ), .
(, , ). : ? , , . provideState
, ; , .
, , , , ( , ), (, , , ).
4.
. 2019- 2018-, , , .
4.1.
, , , — . , . ( ), , - , 9 (), - ( , ( , , ) .
, : , , , . , , .
, 25% (5) , : , ; ( animate
), ( call_extern
).
, - ( ), (, , — , "You won").

4.2.
, :

, , - - " ". :
- (4x2.23''), .
- : , , — .
- ////etc.


5.
( 11 ) 30 40 . 9 4 55 . ( ) 7 41 . — ~4-6 ( 45 ).
: "Darkyen's Time Tracker" JetBrains ( ).
: 2 , — . 45 8 .
: 4777, ( ) — 637.
: cloc
.
30 . ( ) : — ~8 , — ~24 , ( ) — ~8 . .
— 232 ( - , WAV).
WAV?javax.sound.sampled.AudioSystem
, WAV AU , WAV.
28 ( 3 ). — 17 /.
- : , . , , " ", " ". (, ), ( ""/"" - ).
?— , . : . . , , "" : NPC, , (, — ..).
, : , .
— . , : , , , . . , , , , , .
. ( ), :
, , .
GitHub .
(assets) "Releases" "v1.0" .